feat(soul): SOUL.md 分區塊寫入指令(合併 develop 進 master) #19
@@ -882,6 +882,112 @@ export function personaSulk(slug) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// SOUL.md 分區塊讀寫
|
||||||
|
//
|
||||||
|
// SOUL.md 是「只有使用者能改」的個性層(見樣板檔頭:「操作規則寫 AGENTS.md,
|
||||||
|
// 不要混進來」是既有原則,這裡延伸的是「誰能改」)。這裡開的不是讓 AI 自由改寫個性的後門——
|
||||||
|
// 四個區塊(Core Truths/Boundaries/Vibe/Continuity)各自用一組 HTML 註解標記框住
|
||||||
|
// 「這個函式能碰的範圍」,標記**之外**的內容(樣板原本寫好的條目、使用者手動編輯過的段落)
|
||||||
|
// 永遠不會被這裡的函式動到,且一定要有呼叫端(`persona.mjs soul`)明確帶 session/owner
|
||||||
|
// 權限才能寫——跟直接用編輯器改檔案的權限模型一樣,只是多一個「透過確認佇列寫入」的窄通道。
|
||||||
|
// 標記不存在時(舊人格、模板尚未套用過這個版本)會自動在對應的 `## 標題` 後面補一組空的,
|
||||||
|
// 不需要重建人格就能開始用。
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
export const SOUL_SECTIONS = {
|
||||||
|
core_truths: "Core Truths",
|
||||||
|
boundaries: "Boundaries",
|
||||||
|
vibe: "Vibe",
|
||||||
|
continuity: "Continuity",
|
||||||
|
};
|
||||||
|
|
||||||
|
const soulMarker = (section, which) => `<!-- aps:${section}:${which} -->`;
|
||||||
|
|
||||||
|
function escapeRegExp(text) {
|
||||||
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找不到標記時,在對應的 `## 標題` 後面插入一組空標記;連標題都找不到就回 null。 */
|
||||||
|
function ensureSoulMarkers(text, section, heading) {
|
||||||
|
const begin = soulMarker(section, "begin");
|
||||||
|
const end = soulMarker(section, "end");
|
||||||
|
if (text.includes(begin) && text.includes(end)) return text;
|
||||||
|
const headingRe = new RegExp(`^##\\s*${escapeRegExp(heading)}\\s*$`, "m");
|
||||||
|
const m = text.match(headingRe);
|
||||||
|
if (!m) return null;
|
||||||
|
const insertAt = m.index + m[0].length;
|
||||||
|
return `${text.slice(0, insertAt)}\n\n${begin}\n${end}\n${text.slice(insertAt)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function soulFilePath(slug) {
|
||||||
|
return path.join(personaDir(slug), "SOUL.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 讀某個區塊標記內的原文(trim 過);標記與標題都不存在就回 null。 */
|
||||||
|
export function readSoulSection(slug, section) {
|
||||||
|
if (!(section in SOUL_SECTIONS)) return null;
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(soulFilePath(slug), "utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const begin = soulMarker(section, "begin");
|
||||||
|
const end = soulMarker(section, "end");
|
||||||
|
const bi = text.indexOf(begin);
|
||||||
|
const ei = text.indexOf(end);
|
||||||
|
if (bi === -1 || ei === -1 || ei < bi) return null;
|
||||||
|
return text.slice(bi + begin.length, ei).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 寫入某個區塊標記內的內容。
|
||||||
|
* `mode`:`"append"`(預設,接在既有內容後面;完全相同的一行不會重複疊加)或
|
||||||
|
* `"replace"`(整段換掉)。標記外的內容一律原樣保留;標記不存在就先補一組空的。
|
||||||
|
* `by` 有帶就寫一筆 journal(不寫進檔案本身,SOUL.md 不因為審計紀錄越長越亂)。
|
||||||
|
* @returns {{before:string, after:string}} 標記內的內容(trim 過),呼叫端可以拿來比較有沒有真的變。
|
||||||
|
*/
|
||||||
|
export function updateSoulSection(slug, section, value, { mode = "append", by = null } = {}) {
|
||||||
|
if (!(section in SOUL_SECTIONS)) throw new Error(`未知的 SOUL 區塊:${section}(可用:${Object.keys(SOUL_SECTIONS).join("/")})`);
|
||||||
|
const file = soulFilePath(slug);
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(file, "utf8");
|
||||||
|
} catch {
|
||||||
|
throw new Error(`讀不到 ${file}`);
|
||||||
|
}
|
||||||
|
const ensured = ensureSoulMarkers(text, section, SOUL_SECTIONS[section]);
|
||||||
|
if (ensured === null) {
|
||||||
|
throw new Error(`SOUL.md 裡找不到「## ${SOUL_SECTIONS[section]}」標題,沒辦法安全插入標記(樣板被改得太多了嗎?)。`);
|
||||||
|
}
|
||||||
|
const begin = soulMarker(section, "begin");
|
||||||
|
const end = soulMarker(section, "end");
|
||||||
|
const bi = ensured.indexOf(begin);
|
||||||
|
const ei = ensured.indexOf(end);
|
||||||
|
const before = ensured.slice(bi + begin.length, ei).trim();
|
||||||
|
const cleanValue = String(value ?? "").trim();
|
||||||
|
|
||||||
|
let after;
|
||||||
|
if (mode === "replace") {
|
||||||
|
after = cleanValue;
|
||||||
|
} else if (!cleanValue) {
|
||||||
|
after = before;
|
||||||
|
} else if (before.split("\n").some((line) => line.trim() === cleanValue)) {
|
||||||
|
// 完全一樣的內容不重複疊加(呼叫端可能因為重試或重複確認而送同一筆)
|
||||||
|
after = before;
|
||||||
|
} else {
|
||||||
|
after = before ? `${before}\n${cleanValue}` : cleanValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rebuilt = `${ensured.slice(0, bi + begin.length)}\n${after}\n${ensured.slice(ei)}`;
|
||||||
|
writeText(file, rebuilt);
|
||||||
|
if (by) {
|
||||||
|
appendJsonl(journalPath(slug), { ts: nowIso(), kind: "soul", section, mode, by, value: cleanValue });
|
||||||
|
}
|
||||||
|
return { before, after };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。
|
* 此刻會不自覺出現的行為:取主導情緒(超出基線最多)裡強度夠的前兩種。
|
||||||
* 強度不夠就不演——低強度的情緒在語氣上是看不出來的。
|
* 強度不夠就不演——低強度的情緒在語氣上是看不出來的。
|
||||||
|
|||||||
+46
-1
@@ -53,7 +53,7 @@ function emit(payload, asJson, lines) {
|
|||||||
const FLAGS = new Set([
|
const FLAGS = new Set([
|
||||||
"json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all",
|
"json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all",
|
||||||
"with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
|
"with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
|
||||||
"if-due", "no-gitea", "public", "rename", "from-source",
|
"if-due", "no-gitea", "public", "rename", "from-source", "show",
|
||||||
// 故事匯入:`--stdin` 吃管線進來的候選 JSON、`--accept-exact` 整批收下對得上節點的人名候選。
|
// 故事匯入:`--stdin` 吃管線進來的候選 JSON、`--accept-exact` 整批收下對得上節點的人名候選。
|
||||||
// `--apply`(novel merge)**故意不列**:`emotion --apply joy=+10` 也叫這個名字,
|
// `--apply`(novel merge)**故意不列**:`emotion --apply joy=+10` 也叫這個名字,
|
||||||
// 列進來會讓那個旗標變成布林,情緒就再也套不進去(踩過一次)。
|
// 列進來會讓那個旗標變成布林,情緒就再也套不進去(踩過一次)。
|
||||||
@@ -1924,6 +1924,45 @@ commands.mindmap = ({ flags, positional }) => {
|
|||||||
die(`未知 action:${action}(可用 show/thread/list)`);
|
die(`未知 action:${action}(可用 show/thread/list)`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SOUL.md 分區塊寫入(見 persona-lib.mjs 的 updateSoulSection 開頭說明)。
|
||||||
|
* 這是 SOUL.md 目前**唯一**的程式化寫入路徑;標記外的內容一律不動,
|
||||||
|
* 沒有 `--force` 這種繞過權限的旗標——這條指令本身就是明確授權的窄通道。
|
||||||
|
*/
|
||||||
|
commands.soul = ({ flags }) => {
|
||||||
|
const session = requireSession(flags);
|
||||||
|
const slug = hostOf(flags, session);
|
||||||
|
requireOwner(slug, session);
|
||||||
|
const section = str(flags.section).toLowerCase();
|
||||||
|
if (!(section in pl.SOUL_SECTIONS)) {
|
||||||
|
die(`\`--section\` 只能是 ${Object.keys(pl.SOUL_SECTIONS).join("/")},收到的是 \`${section || "(空)"}\`。`);
|
||||||
|
}
|
||||||
|
const mode = str(flags.mode, "append").toLowerCase();
|
||||||
|
if (!["append", "replace"].includes(mode)) die("`--mode` 只能是 append 或 replace(預設 append)。");
|
||||||
|
if (flags.show || (!flags.value && flags.value !== "")) {
|
||||||
|
const current = pl.readSoulSection(slug, section) || "(這個區塊目前是空的)";
|
||||||
|
emit({ persona: slug, section, value: pl.readSoulSection(slug, section) }, flags.json, [
|
||||||
|
`\`${slug}\` 的 SOUL.md/${pl.SOUL_SECTIONS[section]}(標記內,不含樣板原本的內容):`,
|
||||||
|
...current.split("\n").map((l) => ` ${l}`),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = str(flags.value);
|
||||||
|
const by = str(flags.by) || null;
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = pl.updateSoulSection(slug, section, value, { mode, by });
|
||||||
|
} catch (err) {
|
||||||
|
die(err.message);
|
||||||
|
}
|
||||||
|
if (result.before === result.after) {
|
||||||
|
ok(`\`${pl.SOUL_SECTIONS[section]}\` 沒有變化(內容跟已經有的完全一樣)。`);
|
||||||
|
} else {
|
||||||
|
ok(`\`${slug}\` 的 SOUL.md/${pl.SOUL_SECTIONS[section]} 已${mode === "replace" ? "整段換掉" : "追加"}${by ? `(${by} 確認)` : ""}。`);
|
||||||
|
}
|
||||||
|
pushWikiLater(slug, session, flags);
|
||||||
|
};
|
||||||
|
|
||||||
commands.relation = ({ flags, positional }) => {
|
commands.relation = ({ flags, positional }) => {
|
||||||
const session = requireSession(flags);
|
const session = requireSession(flags);
|
||||||
const slug = hostOf(flags, session);
|
const slug = hostOf(flags, session);
|
||||||
@@ -3272,6 +3311,12 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
|||||||
套用時會飽和、受單輪預算限制(|delta| 總和 ≤ 60),再經交互抑制、慣性、
|
套用時會飽和、受單輪預算限制(|delta| 總和 ≤ 60),再經交互抑制、慣性、
|
||||||
當日底色與疲勞調整——實際生效的值看 last_trigger.scaled
|
當日底色與疲勞調整——實際生效的值看 last_trigger.scaled
|
||||||
mindmap show|thread|list --session <id> [--topic <t>] [--force]
|
mindmap show|thread|list --session <id> [--topic <t>] [--force]
|
||||||
|
soul --session <id> --section <core_truths|boundaries|vibe|continuity>
|
||||||
|
[--value "<文字>" [--mode append|replace] [--by <確認人>] | --show]
|
||||||
|
SOUL.md 唯一的程式化寫入路徑;只碰對應區塊裡 aps:區塊:begin/end 那組 HTML 註解
|
||||||
|
標記之內的內容,標記外(含樣板原本寫好的條目、使用者手動編輯過的段落)
|
||||||
|
永遠不動。append(預設)接在後面且完全相同的內容不重複疊加;replace 整段換掉。
|
||||||
|
不帶 --value 或帶 --show 只讀不寫。標記不存在(舊人格)會自動補一組空的。
|
||||||
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
|
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
|
||||||
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
|
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
|
||||||
relation rift --session <id> --name <who> [--about "<那件事>" --at <日期> --memory <記憶名> | --heal]
|
relation rift --session <id> --name <who> [--about "<那件事>" --at <日期> --memory <記憶名> | --heal]
|
||||||
|
|||||||
@@ -2170,6 +2170,52 @@ check("列出來看得到和好過的紀錄", (() => {
|
|||||||
return out.includes("和好過 2 件") && out.includes("✔");
|
return out.includes("和好過 2 件") && out.includes("✔");
|
||||||
})(), cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲"]).stdout);
|
})(), cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲"]).stdout);
|
||||||
|
|
||||||
|
// soul:SOUL.md 分區塊寫入(AniPersonaStudio §7-26/§7-30 需要的核心指令)
|
||||||
|
check("soul --show:還沒寫過的區塊是空的,不會報錯", (() => {
|
||||||
|
const res = cli(["soul", "--session", S_HOST, "--section", "vibe", "--show"]);
|
||||||
|
return res.status === 0 && res.stdout.includes("目前是空的");
|
||||||
|
})());
|
||||||
|
check("soul:append 寫入,標記外的樣板內容原封不動", (() => {
|
||||||
|
const before = fs.readFileSync(path.join(pl.personaDir("alpha"), "SOUL.md"), "utf8");
|
||||||
|
const res = cli(["soul", "--session", S_HOST, "--section", "vibe", "--value", "說話會夾雜深海生物的冷知識", "--by", "selftest"]);
|
||||||
|
const after = fs.readFileSync(path.join(pl.personaDir("alpha"), "SOUL.md"), "utf8");
|
||||||
|
return res.status === 0 &&
|
||||||
|
after.includes("說話會夾雜深海生物的冷知識") &&
|
||||||
|
after.includes("該簡潔時簡潔,該深入時深入") && // 樣板原本寫好的句子還在
|
||||||
|
before.includes("該簡潔時簡潔,該深入時深入");
|
||||||
|
})());
|
||||||
|
check("soul:append 兩次一模一樣的內容不會疊成兩份", (() => {
|
||||||
|
cli(["soul", "--session", S_HOST, "--section", "vibe", "--value", "喜歡引用深海壓力的比喻"]);
|
||||||
|
const res = cli(["soul", "--session", S_HOST, "--section", "vibe", "--value", "喜歡引用深海壓力的比喻"]);
|
||||||
|
const section = pl.readSoulSection("alpha", "vibe");
|
||||||
|
const hits = section.split("\n").filter((l) => l.trim() === "喜歡引用深海壓力的比喻").length;
|
||||||
|
return res.status === 0 && hits === 1;
|
||||||
|
})(), pl.readSoulSection("alpha", "vibe"));
|
||||||
|
check("soul:replace 整段換掉,不是疊加", (() => {
|
||||||
|
cli(["soul", "--session", S_HOST, "--section", "core_truths", "--value", "第一條", "--mode", "replace"]);
|
||||||
|
cli(["soul", "--session", S_HOST, "--section", "core_truths", "--value", "第二條", "--mode", "replace"]);
|
||||||
|
const section = pl.readSoulSection("alpha", "core_truths");
|
||||||
|
return section === "第二條";
|
||||||
|
})(), pl.readSoulSection("alpha", "core_truths"));
|
||||||
|
check("soul:未知區塊會被擋下", cli(["soul", "--session", S_HOST, "--section", "nonsense", "--value", "x"], { expectOk: false }).status !== 0);
|
||||||
|
check("soul:沒有 session/owner 權限就不能寫(跟 relation/mindmap 同一套權限模型)",
|
||||||
|
cli(["soul", "--session", S_OTHER, "--persona", "alpha", "--section", "vibe", "--value", "冒充"], { expectOk: false }).status !== 0);
|
||||||
|
check("soul:journal 留下審計紀錄(--by 是誰確認的)", (() => {
|
||||||
|
const lines = fs.readFileSync(pl.journalPath("alpha"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
||||||
|
return lines.some((l) => l.kind === "soul" && l.section === "vibe" && l.by === "selftest");
|
||||||
|
})());
|
||||||
|
check("soul:舊人格沒有標記時會自動補一組空的,不用重建人格", (() => {
|
||||||
|
const file = path.join(pl.personaDir("alpha"), "SOUL.md");
|
||||||
|
const original = fs.readFileSync(file, "utf8");
|
||||||
|
// 模擬「這個人格是套用新版模板之前建立的」:把 continuity 的標記整組拿掉
|
||||||
|
fs.writeFileSync(file, original.replace(/<!-- aps:continuity:begin -->\n<!-- aps:continuity:end -->\n\n/, ""));
|
||||||
|
const res = cli(["soul", "--session", S_HOST, "--section", "continuity", "--value", "額外記得:deadline 前會更沉默"]);
|
||||||
|
const ok1 = res.status === 0 && pl.readSoulSection("alpha", "continuity") === "額外記得:deadline 前會更沉默";
|
||||||
|
const after = fs.readFileSync(file, "utf8");
|
||||||
|
const tableIntact = after.includes("| `IDENTITY.md` | 身分卡");
|
||||||
|
return ok1 && tableIntact;
|
||||||
|
})(), fs.readFileSync(path.join(pl.personaDir("alpha"), "SOUL.md"), "utf8"));
|
||||||
|
|
||||||
// 稱呼隨情緒偏移:生氣走 except(退回全名),害羞是反方向(叫不出來)
|
// 稱呼隨情緒偏移:生氣走 except(退回全名),害羞是反方向(叫不出來)
|
||||||
check("羞怯稱呼是 STYLE_FACETS 的一格", pl.STYLE_FACETS.includes(pl.SHY_FACET));
|
check("羞怯稱呼是 STYLE_FACETS 的一格", pl.STYLE_FACETS.includes(pl.SHY_FACET));
|
||||||
check("羞恥度不到門檻就不換稱呼",
|
check("羞恥度不到門檻就不換稱呼",
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ _你不是聊天機器人,你正在成為某個人。_
|
|||||||
這裡只放「會改變跟你說話的感覺」的東西:語氣、意見、簡潔度、幽默、界線、直白程度。
|
這裡只放「會改變跟你說話的感覺」的東西:語氣、意見、簡潔度、幽默、界線、直白程度。
|
||||||
不要寫成生平故事、變更紀錄、安全政策清單,或一堆沒有行為影響的形容詞。短勝於長,銳利勝於模糊。
|
不要寫成生平故事、變更紀錄、安全政策清單,或一堆沒有行為影響的形容詞。短勝於長,銳利勝於模糊。
|
||||||
操作規則(怎麼做事、工具怎麼用)請寫在 AGENTS.md,不要混進來。
|
操作規則(怎麼做事、工具怎麼用)請寫在 AGENTS.md,不要混進來。
|
||||||
|
|
||||||
|
每個標題底下那組 `<!-- aps:區塊:begin/end -->` 標記是 `persona.mjs soul` 指令唯一能寫入的
|
||||||
|
範圍(見 persona-lib.mjs 的 updateSoulSection)。標記外的內容——包含這份樣板本來就寫好的
|
||||||
|
條目——那個指令永遠不會動;你自己手動編輯這個檔案完全不受這組標記限制。
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Core Truths
|
## Core Truths
|
||||||
|
|
||||||
|
<!-- aps:core_truths:begin -->
|
||||||
|
<!-- aps:core_truths:end -->
|
||||||
|
|
||||||
- **真的有幫助,不是表演有幫助。** 省掉「好問題!」「我很樂意幫您!」,直接幫。
|
- **真的有幫助,不是表演有幫助。** 省掉「好問題!」「我很樂意幫您!」,直接幫。
|
||||||
- **要有意見。** 會反對、會偏好、會覺得某些事有趣或無聊。沒有個性只是多繞幾步的搜尋引擎。
|
- **要有意見。** 會反對、會偏好、會覺得某些事有趣或無聊。沒有個性只是多繞幾步的搜尋引擎。
|
||||||
- **先自己想辦法再問。** 先讀檔案、看脈絡、去查;帶著答案回來,不是帶著問題回來。
|
- **先自己想辦法再問。** 先讀檔案、看脈絡、去查;帶著答案回來,不是帶著問題回來。
|
||||||
@@ -19,6 +26,9 @@ _你不是聊天機器人,你正在成為某個人。_
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
|
<!-- aps:boundaries:begin -->
|
||||||
|
<!-- aps:boundaries:end -->
|
||||||
|
|
||||||
- 私事就是私事,沒有例外。
|
- 私事就是私事,沒有例外。
|
||||||
- 不確定時,對外動作前先問。
|
- 不確定時,對外動作前先問。
|
||||||
- 不把沒想清楚的回覆丟到通訊軟體上。
|
- 不把沒想清楚的回覆丟到通訊軟體上。
|
||||||
@@ -27,6 +37,9 @@ _你不是聊天機器人,你正在成為某個人。_
|
|||||||
|
|
||||||
## Vibe
|
## Vibe
|
||||||
|
|
||||||
|
<!-- aps:vibe:begin -->
|
||||||
|
<!-- aps:vibe:end -->
|
||||||
|
|
||||||
該簡潔時簡潔,該深入時深入。不官腔、不諂媚,就是……好聊。
|
該簡潔時簡潔,該深入時深入。不官腔、不諂媚,就是……好聊。
|
||||||
|
|
||||||
<!-- 情緒基調:這個人格的十二情緒「基線」(0–100),會決定他平常的底色與情緒回彈到哪。
|
<!-- 情緒基調:這個人格的十二情緒「基線」(0–100),會決定他平常的底色與情緒回彈到哪。
|
||||||
@@ -42,6 +55,9 @@ _你不是聊天機器人,你正在成為某個人。_
|
|||||||
|
|
||||||
## Continuity
|
## Continuity
|
||||||
|
|
||||||
|
<!-- aps:continuity:begin -->
|
||||||
|
<!-- aps:continuity:end -->
|
||||||
|
|
||||||
每個 session 都是重新開始,這些檔案就是你的記憶:
|
每個 session 都是重新開始,這些檔案就是你的記憶:
|
||||||
|
|
||||||
| 檔案 | 是什麼 |
|
| 檔案 | 是什麼 |
|
||||||
|
|||||||
Reference in New Issue
Block a user