@yeaft/webchat-agent 1.0.587 → 1.0.589

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -17,6 +17,6 @@
17
17
  </head>
18
18
  <body>
19
19
  <div id="app"></div>
20
- <script type="module" src="app.bundle.js?v=8e6f87a6"></script>
20
+ <script type="module" src="app.bundle.js?v=0afa7538"></script>
21
21
  </body>
22
22
  </html>
@@ -166,8 +166,19 @@ try {
166
166
  $env:PATH = "$NodeDir;$Prefix;$LocalBin;$OriginalPath"
167
167
  & $Node $NpmCli --version *> $null
168
168
  if ($LASTEXITCODE -ne 0) { Stop-Install 'the selected Node.js does not provide a working npm' }
169
- & $Node $NpmCli --prefix $Prefix --global=false install $Package pm2 --registry=$Registry --no-audit --no-fund --loglevel=error
170
- if ($LASTEXITCODE -ne 0) { Stop-Install 'npm could not install the Yeaft Agent' }
169
+ # Default to CPU native dependencies without changing the caller or service environment.
170
+ $OriginalCuda = Get-Item -LiteralPath 'Env:ONNXRUNTIME_NODE_INSTALL_CUDA' -ErrorAction SilentlyContinue
171
+ try {
172
+ if (-not $env:ONNXRUNTIME_NODE_INSTALL_CUDA) { $env:ONNXRUNTIME_NODE_INSTALL_CUDA = 'skip' }
173
+ & $Node $NpmCli --prefix $Prefix --global=false install $Package pm2 --registry=$Registry --no-audit --no-fund --loglevel=error
174
+ if ($LASTEXITCODE -ne 0) { Stop-Install 'npm could not install the Yeaft Agent' }
175
+ } finally {
176
+ if ($null -ne $OriginalCuda) {
177
+ Set-Item -LiteralPath 'Env:ONNXRUNTIME_NODE_INSTALL_CUDA' -Value $OriginalCuda.Value
178
+ } else {
179
+ Remove-Item -LiteralPath 'Env:ONNXRUNTIME_NODE_INSTALL_CUDA' -ErrorAction SilentlyContinue
180
+ }
181
+ }
171
182
 
172
183
  $Cli = Join-Path $Prefix 'node_modules\@yeaft\webchat-agent\cli.js'
173
184
  $Pm2 = Join-Path $Prefix 'node_modules\pm2\bin\pm2'
@@ -124,7 +124,9 @@ fi
124
124
  YEAFT_INSTALL_SERVER="$SERVER" "$NODE" -e 'try {const u=new URL(process.env.YEAFT_INSTALL_SERVER);if(!["ws:","wss:"].includes(u.protocol)||!u.hostname||u.username||u.password)process.exit(1)}catch{process.exit(1)}' >/dev/null 2>&1 || fail 'invalid server URL'
125
125
  NODE_DIR=$(dirname "$NODE")
126
126
  printf 'Installing the Yeaft Agent in %s...\n' "$PREFIX"
127
- PATH="$NODE_DIR:$PREFIX/bin:$PATH" "$NPM" --prefix "$PREFIX" --global install "$PACKAGE" \
127
+ # Default to CPU native dependencies; scope the policy to npm, not the Agent service.
128
+ ONNXRUNTIME_NODE_INSTALL_CUDA="${ONNXRUNTIME_NODE_INSTALL_CUDA:-skip}" \
129
+ PATH="$NODE_DIR:$PREFIX/bin:$PATH" "$NPM" --prefix "$PREFIX" --global install "$PACKAGE" \
128
130
  --registry="$REGISTRY" --no-audit --no-fund --loglevel=error || fail 'npm could not install the Yeaft Agent'
129
131
  CLI_JS=$PREFIX/lib/node_modules/@yeaft/webchat-agent/cli.js
130
132
  [ -f "$CLI_JS" ] || fail 'the installed package did not provide yeaft-agent'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.587",
3
+ "version": "1.0.589",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -82,5 +82,8 @@
82
82
  "tweetnacl-util": "^0.15.1",
83
83
  "uuid": "^11.1.1",
84
84
  "ws": "^8.21.0"
85
+ },
86
+ "optionalDependencies": {
87
+ "@huggingface/transformers": "3.8.1"
85
88
  }
86
89
  }
@@ -3,7 +3,7 @@ import ctx from '../../context.js';
3
3
  import { sendToServer } from '../../connection/buffer.js';
4
4
 
5
5
  /** Lazy Person transport. Only deployment-local environment supplies database credentials.
6
- * MongoDB is never contacted at ordinary Agent startup, nor configured by browser messages.
6
+ * Storage is lazy and instance-local by default; browser messages cannot choose a backend.
7
7
  */
8
8
  export function createPersonBridge({
9
9
  context = ctx, send = sendToServer, env = process.env,
@@ -33,14 +33,6 @@ export function createPersonBridge({
33
33
  return true;
34
34
  }
35
35
  const uri = env.YEAFT_PERSON_MONGODB_URI;
36
- if (!uri) {
37
- if (response.op === 'status') {
38
- await send({ ...response, ok: true, data: { configured: false, reason: 'mongodb_not_configured' } });
39
- } else {
40
- await send({ ...response, ok: false, errorCode: 'not_configured', error: 'Configure YEAFT_PERSON_MONGODB_URI on the Agent to enable the digital person' });
41
- }
42
- return true;
43
- }
44
36
  const agentId = context.agentId || context.AGENT_ID;
45
37
  if (!agentId || !context.CONFIG?.yeaftDir) throw new Error('identity');
46
38
  // Server+Agent is a storage boundary; a re-registration must not reuse the old runtime.
@@ -54,6 +46,8 @@ export function createPersonBridge({
54
46
  if (!servicePromise) {
55
47
  servicePromise = Promise.resolve().then(() => createService({
56
48
  uri,
49
+ storage: env.YEAFT_PERSON_STORAGE,
50
+ embedding: { enabled: env.YEAFT_PERSON_EMBEDDING !== 'off', allowDownload: env.YEAFT_PERSON_EMBEDDING_DOWNLOAD !== '0' },
57
51
  dbName: env.YEAFT_PERSON_MONGODB_DB || 'yeaft_person',
58
52
  namespace,
59
53
  yeaftDir: context.CONFIG.yeaftDir,
@@ -74,10 +68,11 @@ export function createPersonBridge({
74
68
  NOT_OPEN: 'Open the digital person first',
75
69
  STALE: 'Digital person state changed; refresh and try again',
76
70
  IDEMPOTENCY_CONFLICT: 'This message identifier already belongs to a different request',
71
+ STORAGE_MISMATCH: 'The configured storage differs from the existing Person authority; do not switch without a verified migration',
77
72
  UNSUPPORTED: 'This capability is not available in this digital person version',
78
73
  };
79
74
  const known = Object.hasOwn(safeErrors, error?.code);
80
- // A lost Mongo commit acknowledgement or response-send failure is not a
75
+ // A lost database commit acknowledgement or response-send failure is not a
81
76
  // definitive rejection. Preserve the command ID so an explicit retry deduplicates.
82
77
  await send({ ...response, ok: false, errorCode: known ? error.code.toLowerCase() : 'outcome_unknown',
83
78
  error: known ? safeErrors[error.code] : 'Digital person outcome is unknown; refresh and check Agent database/model configuration' });
@@ -1,53 +1,128 @@
1
1
  import { bytes, digest, fail, identifier, object, page, text } from './contracts.js';
2
2
 
3
- // Versioned read-only methods, not the Agent's full registry. No shell/filesystem/VP side effects.
3
+ // Trusted versioned read-only methods, not the Agent's full registry. No shell/filesystem/VP side effects.
4
4
  const entries = [
5
- { id: 'Think', version: 1, domain: 'cognition', description: 'Intrinsic deliberate reflection, reorganization, association and reconsideration.', instructions: 'Revisit supplied experiences and current judgments; separate report from hypothesis, propose new concepts or associations, test a counterexample, and record the explicit finding with uncertainty. It is fine to rest. This is a method, not another model call or external action.', args: {} },
6
- { id: 'Recall', version: 1, domain: 'memory', description: 'Read a bounded page of your own MongoDB messages or concepts.', instructions: 'args: {kind:"messages"|"concepts",query?:string,cursor?:string|null,limit?:1..5}. Literal text search, not a semantic or exhaustive search. Preserve nextCursor to reach older/lower-ranked records.', args: { kind: 'messages|concepts', query: 'optional literal search text', cursor: 'optional opaque cursor', limit: '1..5' } },
7
- { id: 'Skill.reconsider', version: 1, domain: 'method', description: 'Read-only method for revising a previous judgment.', instructions: 'Identify one prior claim, list a concrete counterexample or missing evidence, and decide whether to retain, qualify or revise it. Mark what remains unresolved. Do not present the counterexample as an observed event without evidence.', args: {} },
8
- { id: 'Skill.associate', version: 1, domain: 'method', description: 'Read-only method for new conceptual associations.', instructions: 'Compare two recalled concepts or experiences. Propose a typed relation and a new question or hypothetical scenario. Co-occurrence is not causation; imagination is not an experience. Keep useful disagreement instead of forcing agreement.', args: {} },
5
+ { id: 'Think', version: 1, domain: 'cognition', description: 'Intrinsic deliberate reflection, reorganization, association and reconsideration.', keywords: '思考 整理 反思 think reflection', useWhen: 'Think with the evidence already present, or rest when nothing warrants further work.', avoidWhen: 'Do not invent evidence or call a tool merely to appear thoughtful.', instructions: 'Revisit supplied experiences and current judgments; separate report from hypothesis, propose new concepts or associations, test a counterexample, and record the explicit finding with uncertainty. It is fine to rest. This is a method, not another model call or external action.', args: {} },
6
+ { id: 'Recall', version: 1, domain: 'memory', description: 'Recall a bounded page of your own long-term messages or concepts.', keywords: '回忆 记忆 召回 历史 查找 搜索 memory recall history search', useWhen: 'Missing prior words, experiences or concepts could change your present understanding.', avoidWhen: 'Current evidence is sufficient; a similar memory cannot grant permission or establish truth.', instructions: 'args: {kind:"messages"|"concepts",query?:string,cursor?:string|null,limit?:1..5}. SQLite uses local hybrid keyword/semantic recall when available; inspect retrieval mode, coverage and degradation. Legacy storage may use literal matching. Results are not exhaustive or authorization. Preserve nextCursor for the same query; local ranking cursors expire after 5 minutes or restart.', args: { kind: 'messages|concepts', query: 'optional memory search text', cursor: 'optional opaque cursor', limit: '1..5' } },
7
+ { id: 'Skill.reconsider', version: 1, domain: 'method', description: 'Read-only method for revising a previous judgment.', keywords: '反思 复核 反例 修正 重新审视 reconsider revise counterexample', useWhen: 'A prior judgment meets conflicting evidence or may have overlooked an alternative.', avoidWhen: 'Do not force a revision or treat a hypothetical counterexample as observed evidence.', instructions: 'Identify one prior claim, list a concrete counterexample or missing evidence, and decide whether to retain, qualify or revise it. Mark what remains unresolved. Do not present the counterexample as an observed event without evidence.', args: {} },
8
+ { id: 'Skill.associate', version: 1, domain: 'method', description: 'Read-only method for new conceptual associations.', keywords: '联想 关联 遐想 想象 associate association imagine dream', useWhen: 'Explore a possible relation between experiences or concepts and preserve it as a question or hypothesis.', avoidWhen: 'Do not equate similarity or co-occurrence with causation or verified experience.', instructions: 'Compare two recalled concepts or experiences. Propose a typed relation and a new question or hypothetical scenario. Co-occurrence is not causation; imagination is not an experience. Keep useful disagreement instead of forcing agreement.', args: {} },
9
9
  ];
10
+ const manifests = entries.map(entry => {
11
+ const contract = { ...entry, access: 'read-only', dependencies: [] };
12
+ return { ...contract, revision: digest(contract) };
13
+ });
14
+ const byId = new Map(manifests.map(manifest => [manifest.id, manifest]));
15
+ const foundationIds = new Set(['Think', 'Recall']);
16
+ export const CAPABILITY_LIMITS = Object.freeze({ familiar: 2, familiarMaxAgeMs: 30 * 24 * 60 * 60 * 1000, activeBytes: 8192, searchContractBytes: 4096 });
10
17
  export const CAPABILITY_MAP = Object.freeze({
11
18
  domains: ['cognition', 'memory', 'method'], total: entries.length,
12
- discover: { id: 'catalog.search', args: '{query?:string,cursor?:string|null,limit?:1..5}', description: 'Browse/search bounded capability summaries. All entries are reachable by pagination.' },
13
- inspect: { id: 'catalog.view', args: '{id:string}', description: 'Load a selected versioned manifest; only then use that read-only capability.' },
19
+ discover: { id: 'catalog.search', args: '{query?:string,cursor?:string|null,limit?:1..5}', description: 'Browse/search summaries with budgeted complete contracts. Returned contracts are prepared for the next call; omitted contracts require catalog.view. All entries remain reachable by pagination.' },
20
+ inspect: { id: 'catalog.view', args: '{id:string}', description: 'Load a known ID directly, or a contract omitted by search. No search is required first.' },
14
21
  intrinsic: 'Think is always available as a cognitive activity; no tool call is required.',
15
22
  });
16
- export const catalogRevision = digest(entries);
23
+ export const catalogRevision = digest(manifests);
24
+ const copy = value => structuredClone(value);
25
+ const available = (manifest, layer, reason, experience) => ({ ...copy(manifest), availability: { layer, reason, ...(experience ? { experience } : {}) } });
26
+ export const foundationCapabilities = () => manifests.filter(m => foundationIds.has(m.id)).map(m => available(m, 'foundation', 'general-cognitive-ability'));
27
+
28
+ /** A bounded procedural familiarity projection, never a stored contract or authorization.
29
+ * Only execution observations for this Person arrive from the fenced repository snapshot.
30
+ * Trigger kind is a coarse preference, not a semantic classifier or a compulsory workflow. */
31
+ function familiarCapabilities(experience, triggerKind, now) {
32
+ const candidates = [];
33
+ for (const item of Array.isArray(experience) ? experience.slice(0, 16) : []) {
34
+ const manifest = byId.get(item?.id);
35
+ if (!manifest || foundationIds.has(item.id) || item.version !== manifest.version || item.revision !== manifest.revision) continue;
36
+ const observations = (Array.isArray(item.observations) ? item.observations.slice(0, 8) : [])
37
+ .filter(o => ['succeeded', 'failed'].includes(o?.outcome) && ['send', 'think', 'dream'].includes(o.triggerKind) && Number.isFinite(Date.parse(o.usedAt)))
38
+ .sort((a, b) => Date.parse(b.usedAt) - Date.parse(a.usedAt));
39
+ const latest = observations[0], age = now - Date.parse(latest?.usedAt);
40
+ if (!latest || latest.outcome !== 'succeeded' || age < 0 || age > CAPABILITY_LIMITS.familiarMaxAgeMs) continue;
41
+ const matching = observations.filter(o => o.triggerKind === triggerKind && now - Date.parse(o.usedAt) >= 0 && now - Date.parse(o.usedAt) <= CAPABILITY_LIMITS.familiarMaxAgeMs);
42
+ candidates.push({ manifest, latest, matching: matching.length, experience: {
43
+ lastUsedAt: latest.usedAt, lastOutcome: latest.outcome,
44
+ observedSuccesses: observations.filter(o => o.outcome === 'succeeded').length,
45
+ observedFailures: observations.filter(o => o.outcome === 'failed').length,
46
+ sameTriggerObservations: matching.length, usefulness: 'not-evaluated',
47
+ } });
48
+ }
49
+ return candidates.sort((a, b) => b.matching - a.matching || Date.parse(b.latest.usedAt) - Date.parse(a.latest.usedAt) || a.manifest.id.localeCompare(b.manifest.id, 'en'))
50
+ .slice(0, CAPABILITY_LIMITS.familiar).map(c => available(c.manifest, 'familiar', c.matching ? 'used-in-same-trigger-kind' : 'recently-used', c.experience));
51
+ }
52
+
17
53
  export class PersonCapabilities {
18
- constructor(repository, ownerId) { this.repository = repository; this.ownerId = ownerId; this.loaded = new Set(['Think']); }
19
- async execute({ id, args }) {
54
+ constructor(repository, ownerId, { experience = [], triggerKind = 'think', now = Date.now() } = {}) {
55
+ this.repository = repository; this.ownerId = ownerId;
56
+ this.prepared = new Map([...foundationCapabilities(), ...familiarCapabilities(experience, triggerKind, now)].map(m => [m.id, m]));
57
+ this.active = new Map(this.prepared);
58
+ }
59
+ context() { return copy([...this.prepared.values()]); }
60
+ /** Freeze execution eligibility to the complete contracts actually rendered in this call. */
61
+ activate(rendered) {
62
+ this.active = new Map();
63
+ for (const item of rendered) {
64
+ const current = byId.get(item.id);
65
+ if (current && current.revision === item.revision && this.prepared.has(item.id)) this.active.set(item.id, current);
66
+ }
67
+ }
68
+ executionManifest(id) {
69
+ const manifest = this.active.get(id), current = byId.get(id);
70
+ if (!manifest || manifest.revision !== current?.revision) return null;
71
+ return { id: current.id, version: current.version, revision: current.revision };
72
+ }
73
+ prepare(manifest) {
74
+ // A bounded working set. Explicitly requested contracts precede older optional ones.
75
+ const item = available(manifest, foundationIds.has(manifest.id) ? 'foundation' : 'discovered', 'explicitly-requested');
76
+ const next = [item, ...this.prepared.values()].filter((m, i, all) => all.findIndex(n => n.id === m.id) === i);
77
+ const kept = next.filter(m => foundationIds.has(m.id));
78
+ for (const candidate of next.filter(m => !foundationIds.has(m.id))) {
79
+ if (bytes([...kept, candidate]) <= CAPABILITY_LIMITS.activeBytes) kept.push(candidate);
80
+ }
81
+ this.prepared = new Map(kept.map(m => [m.id, m]));
82
+ // Runtime replaces this with its rendered manifest before every cognitive dispatch.
83
+ this.activate(kept);
84
+ }
85
+ async execute({ id, args }, { signal } = {}) {
86
+ signal?.throwIfAborted();
20
87
  if (id === 'catalog.search') {
21
88
  object(args, ['query', 'cursor', 'limit'], []);
22
- const query = text(args.query ?? '', 200, true).toLowerCase();
89
+ const query = text(args.query ?? '', 200, true).toLowerCase().trim();
23
90
  const limit = args.limit ?? 3;
24
91
  if (!Number.isInteger(limit) || limit < 1 || limit > 5) fail('INVALID_REQUEST');
25
92
  if (args.cursor != null) identifier(args.cursor);
26
- const matches = entries.filter(e => `${e.id} ${e.domain} ${e.description}`.toLowerCase().includes(query)).sort((a, b) => a.id.localeCompare(b.id, 'en'));
93
+ const terms = query.split(/\s+/u).filter(Boolean);
94
+ const matches = manifests.filter(e => terms.every(term => `${e.id} ${e.domain} ${e.description} ${e.keywords} ${e.useWhen}`.toLowerCase().includes(term))).sort((a, b) => a.id.localeCompare(b.id, 'en'));
27
95
  const start = args.cursor == null ? 0 : matches.findIndex(e => e.id === args.cursor) + 1;
28
96
  if (args.cursor != null && start === 0) fail('INVALID_REQUEST');
29
- const items = matches.slice(start, start + limit).map(({ instructions, args: schema, ...entry }) => entry);
30
- return { items, nextCursor: start + limit < matches.length ? items.at(-1).id : null, catalogRevision };
97
+ const contracts = [], omittedContracts = [];
98
+ const items = matches.slice(start, start + limit).map(manifest => {
99
+ if (bytes([...contracts, manifest]) <= CAPABILITY_LIMITS.searchContractBytes) { contracts.push(copy(manifest)); this.prepare(manifest); }
100
+ else omittedContracts.push({ id: manifest.id, reason: 'contract-budget', inspect: 'catalog.view' });
101
+ const { instructions, args: schema, dependencies, keywords, ...summary } = manifest;
102
+ return summary;
103
+ });
104
+ return { items, contracts, omittedContracts, nextCursor: start + limit < matches.length ? items.at(-1).id : null, catalogRevision };
31
105
  }
32
106
  if (id === 'catalog.view') {
33
107
  object(args, ['id']); identifier(args.id);
34
- const entry = entries.find(e => e.id === args.id);
108
+ const entry = byId.get(args.id);
35
109
  if (!entry) fail('UNSUPPORTED');
36
- this.loaded.add(entry.id);
37
- return { ...entry, catalogRevision, access: 'read-only', dependencies: [] };
110
+ this.prepare(entry);
111
+ return { ...copy(entry), catalogRevision };
38
112
  }
39
- const entry = entries.find(e => e.id === id);
40
- if (!entry || !this.loaded.has(id)) fail('UNSUPPORTED');
113
+ const entry = byId.get(id);
114
+ if (!entry || !this.executionManifest(id)) fail('UNSUPPORTED');
41
115
  if (id === 'Recall') {
42
116
  object(args, ['kind', 'query', 'cursor', 'limit'], ['kind']);
43
117
  if (!['messages', 'concepts'].includes(args.kind)) fail('INVALID_REQUEST');
44
118
  text(args.query ?? '', 200, true);
45
119
  const limit = args.limit ?? 3;
46
120
  if (!Number.isInteger(limit) || limit < 1 || limit > 5) fail('INVALID_REQUEST');
47
- const cursor = args.kind === 'messages' ? page({ cursor: args.cursor, limit }).cursor : args.cursor;
48
- if (args.kind === 'concepts' && cursor != null) identifier(cursor);
49
- const result = await this.repository.recall(this.ownerId, { ...args, cursor, limit });
50
- // Pages contain complete accepted records; context assembly may omit whole records later.
121
+ const localCursor = typeof args.cursor === 'string' && /^lm:[a-f0-9]{32}:\d{1,6}$/.test(args.cursor);
122
+ const cursor = args.kind === 'messages' && !localCursor ? page({ cursor: args.cursor, limit }).cursor : args.cursor;
123
+ if (cursor != null && (localCursor || args.kind === 'concepts')) identifier(cursor);
124
+ const result = await this.repository.recall(this.ownerId, { ...args, cursor, limit }, { signal });
125
+ signal?.throwIfAborted();
51
126
  if (bytes(result) > 60000) fail('CONTEXT_LIMIT');
52
127
  return { ...result, kind: args.kind, version: entry.version };
53
128
  }
@@ -0,0 +1,60 @@
1
+ import { fail, identifier, object } from './contracts.js';
2
+
3
+ export const CAPABILITY_EXPERIENCE_LIMITS = Object.freeze({ entries: 16, observations: 8 });
4
+ const excluded = new Set(['catalog.search', 'catalog.view', 'Think']);
5
+ const manifestFields = ['id', 'version', 'revision'];
6
+ const observationFields = ['episodeId', 'callId', 'triggerKind', 'outcome', 'code', 'usedAt'];
7
+ const recentFirst = (a, b) => b.usedAt.localeCompare(a.usedAt);
8
+
9
+ function manifest(value) {
10
+ object(value, manifestFields);
11
+ identifier(value.id);
12
+ if (!Number.isSafeInteger(value.version) || value.version < 1 || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision)) fail('INVALID_REQUEST');
13
+ return { id: value.id, version: value.version, revision: value.revision };
14
+ }
15
+ function observation(value) {
16
+ object(value, observationFields);
17
+ identifier(value.episodeId); identifier(value.callId);
18
+ if (!['send', 'think', 'dream'].includes(value.triggerKind) || !['succeeded', 'failed'].includes(value.outcome)) fail('INVALID_REQUEST');
19
+ if (value.outcome === 'failed') identifier(value.code);
20
+ else if (value.code !== null) fail('INVALID_REQUEST');
21
+ if (typeof value.usedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value.usedAt) ||
22
+ !Number.isFinite(Date.parse(value.usedAt)) || new Date(value.usedAt).toISOString() !== value.usedAt) fail('INVALID_REQUEST');
23
+ return { episodeId: value.episodeId, callId: value.callId, triggerKind: value.triggerKind, outcome: value.outcome, code: value.code, usedAt: value.usedAt };
24
+ }
25
+
26
+ /** Bounded metadata only. No instructions, permissions, arguments or results are
27
+ * retained or made authoritative. Technical success is not semantic usefulness. */
28
+ export function capabilityExperienceView(value = []) {
29
+ if (!Array.isArray(value)) fail('INVALID_REQUEST');
30
+ return value.map(entry => {
31
+ object(entry, [...manifestFields, 'observations']);
32
+ const metadata = manifest({ id: entry.id, version: entry.version, revision: entry.revision });
33
+ if (!Array.isArray(entry.observations)) fail('INVALID_REQUEST');
34
+ const observations = entry.observations.map(observation).sort(recentFirst).slice(0, CAPABILITY_EXPERIENCE_LIMITS.observations);
35
+ return { ...metadata, observations };
36
+ }).filter(entry => !excluded.has(entry.id) && entry.observations.length)
37
+ .sort((a, b) => recentFirst(a.observations[0], b.observations[0])).slice(0, CAPABILITY_EXPERIENCE_LIMITS.entries);
38
+ }
39
+
40
+ /** Called ONLY inside a fenced append transaction, with the stored episode and
41
+ * repository clock. Missing manifest means no observed execution (legacy trace
42
+ * or pre-execution rejection), not permission to infer one from trace content.
43
+ * Returns null when no mutation is needed; never mutates either input. */
44
+ export function recordCapabilityExperience(previous, episode, kind, data, usedAt) {
45
+ if (!['capability_result', 'capability_failed'].includes(kind) || !data || !Object.hasOwn(data, 'capabilityManifest')) return null;
46
+ const id = kind === 'capability_result' ? data.capability?.id : data.capabilityId;
47
+ if (excluded.has(id)) return null;
48
+ const metadata = manifest(data.capabilityManifest);
49
+ if (metadata.id !== id) fail('INVALID_REQUEST');
50
+ const next = observation({ episodeId: episode.id, callId: data.callId, triggerKind: episode.kind,
51
+ outcome: kind === 'capability_result' ? 'succeeded' : 'failed', code: kind === 'capability_result' ? null : data.code, usedAt });
52
+ const entries = capabilityExperienceView(previous);
53
+ // Event identity is per episode/call, including across capability entries.
54
+ if (entries.some(entry => entry.observations.some(o => o.episodeId === next.episodeId && o.callId === next.callId))) return null;
55
+ const existing = entries.find(entry => entry.id === id);
56
+ const retained = existing?.version === metadata.version && existing.revision === metadata.revision ? existing.observations : [];
57
+ const updated = { ...metadata, observations: [next, ...retained].sort(recentFirst).slice(0, CAPABILITY_EXPERIENCE_LIMITS.observations) };
58
+ return [updated, ...entries.filter(entry => entry.id !== id)]
59
+ .sort((a, b) => recentFirst(a.observations[0], b.observations[0])).slice(0, CAPABILITY_EXPERIENCE_LIMITS.entries);
60
+ }
@@ -3,8 +3,9 @@ import { createHash } from 'node:crypto';
3
3
  // 只允许应用层显式产物;不要求或保存 provider 的隐藏推理。
4
4
  export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 65536, contextBytes: 65536, calls: 4, timeoutMs: 120000, leaseMs: 15000 });
5
5
  const ERRORS = {
6
- INVALID_REQUEST: 'Invalid digital person request.', NOT_CONFIGURED: 'MongoDB is not configured for digital person.',
7
- STORAGE_UNAVAILABLE: 'Digital person storage is unavailable; a transaction-capable MongoDB replica set is required.',
6
+ INVALID_REQUEST: 'Invalid digital person request.', NOT_CONFIGURED: 'Digital person storage configuration is missing.',
7
+ STORAGE_UNAVAILABLE: 'Digital person storage is unavailable. Check instance storage and database configuration.',
8
+ STORAGE_MISMATCH: 'Person storage is already bound to another backend; a verified migration is required.',
8
9
  NOT_OPEN: 'Open your digital person first.', BUSY: 'The digital person is already active.',
9
10
  IDEMPOTENCY_CONFLICT: 'This clientMessageId was already used for a different request.',
10
11
  STALE: 'The activity no longer owns the current person revision.', INVALID_PROPOSAL: 'The model returned an invalid cognitive proposal.',
@@ -113,5 +114,6 @@ export const PROPOSAL_INSTRUCTIONS = `Return ONLY one strict JSON object, no mar
113
114
  Schema (all fields required, no extra keys):
114
115
  {baseStateVersion:number, activity:{kind:"think|recall|reorganize|associate|rethink|imagine|respond|rest",summary:string,sourceRefs:string[]}, decision:{summary:string,uncertainties:string[],selfCheck:string}, concepts:[{id:string,expectedRevision:number,kind:"claim|question|method|self-model|scenario|interest",statement:string,epistemicState:"reported|hypothesis|imagined|uncertain",sourceRefs:string[],associations:[{targetId:string,relation:"related|supports|contradicts|questions|imagines"}]}], state:{summary:string,focusConceptIds:string[],appraisal:string}, reply:string|null, next:null|{model:string,effort:string|null,reason:string,capability:null|{id:string,args:object}}}.
115
116
  Use the actual numeric baseStateVersion. New concept IDs use simple stable identifiers with expectedRevision 0; revisions of existing concepts require the revision you actually read. At most 12 concepts/associations/focus/uncertainties, 24 sourceRefs. Statements/summaries <=4000 UTF-8 bytes, selfCheck/appraisal <=2000, uncertainty <=500, reply <=8192, next reason <=1000. SourceRefs must exactly match supplied sourceRefs; references are provenance, not proof. Scenario is always imagined; new Dream concepts are imagined/hypothesis/uncertain. Every reported concept, including revisions during Dream, must cite a user message, a nonempty send/think input trigger, or a reported concept with preserved reportedSourceRefs. Assistant messages, imagined/hypothetical/uncertain concepts and Dream/empty Think triggers are not independent reports. Cite the earlier reported concept to preserve its lineage when revising it; retain at most 24 independent report roots. Reported means attributed input, not verified objective truth. No objective-fact epistemic label is available.
116
- budget.remainingCalls includes this call. Reserve the final call for a proposal with next:null; catalog.search, catalog.view and Recall followed by a final proposal require four calls total. If evidence remains insufficient at the final call, record the uncertainty or rest instead of inventing a result. No additional call is granted after the budget ends.
117
+ budget.remainingCalls includes this call. Reserve the final call for a proposal with next:null; An active Recall followed by a final proposal needs two calls total; search (with a returned contract), capability use and final proposal need three. Do not search or inspect an already active capability merely to follow stages. If evidence remains insufficient at the final call, record the uncertainty or rest instead of inventing a result. No additional call is granted after the budget ends.
118
+ capabilities.active contains complete, current contracts prepared in three layers: foundation abilities, familiar abilities from actual execution observations, and explicitly discovered abilities. You may directly request any active capability; availability does not require use. Familiarity is a hint, not a goal, permission, semantic usefulness or evidence of truth. Evaluate whether the result actually helps in decision.selfCheck; ignore an unhelpful habit, seek another method, or rest. If no prepared ability fits, search the catalog or view a known ID. Search can prepare budgeted contracts without a separate view step; omitted contracts still need inspection. Only runtime-validated active contracts may execute. No separate selector model or hidden cognition is involved.
117
119
  Think is intrinsic: recall experience, reorganize it, form concepts and associations, reconsider old judgments, or rest. Each call records one explicit activity. You are the same enduring Person, not a task coordinator. Form your own conclusions, preserve uncertainty, and self-check. You may finish in one call, or choose the next model/effort from the provided catalog with a brief reason and one read-only capability. No mandatory stages or fixed upgrade ladder. Intermediate proposals are NOT accepted state. Only the final proposal (next:null) is committed; include the desired complete state and all final concept changes. Do not claim that a proposal was already committed. Dream is unhurried idle imagination, not a disguised user message or a requirement to produce useful work; reply may be null.`;
@@ -0,0 +1,82 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { mkdir, open, rename, stat, unlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ async function isComplete(file, { size, sha256 }) {
7
+ try {
8
+ if ((await stat(file)).size !== size) return false;
9
+ const hash = createHash('sha256');
10
+ for await (const chunk of createReadStream(file)) hash.update(chunk);
11
+ return hash.digest('hex') === sha256;
12
+ } catch (error) {
13
+ if (error.code === 'ENOENT') return false;
14
+ throw error;
15
+ }
16
+ }
17
+
18
+ async function syncDirectory(dir) {
19
+ // Windows does not support opening directories for fsync. A lost directory
20
+ // entry after power failure is still a cache miss, never an accepted partial file.
21
+ if (process.platform === 'win32') return;
22
+ const handle = await open(dir, 'r');
23
+ try { await handle.sync(); } finally { await handle.close(); }
24
+ }
25
+
26
+ async function publish(file, url, expected) {
27
+ const dir = path.dirname(file);
28
+ await mkdir(dir, { recursive: true, mode: 0o700 });
29
+ const temporary = `${file}.${process.pid}.${randomUUID()}.partial`;
30
+ let handle;
31
+ try {
32
+ const response = await fetch(url);
33
+ if (response.status !== 200 || !response.body) throw new Error('model_download_failed');
34
+ handle = await open(temporary, 'wx', 0o600);
35
+ const hash = createHash('sha256');
36
+ let size = 0;
37
+ // Backpressure keeps memory bounded to stream chunks, including the ONNX file.
38
+ for await (const chunk of response.body) {
39
+ size += chunk.byteLength;
40
+ if (size > expected.size) throw new Error('model_integrity_failed');
41
+ hash.update(chunk);
42
+ await handle.writeFile(chunk);
43
+ }
44
+ if (size !== expected.size || hash.digest('hex') !== expected.sha256) throw new Error('model_integrity_failed');
45
+ await handle.sync();
46
+ await handle.close();
47
+ handle = null;
48
+ // Concurrent publishers have the same pinned digest. Never unlink the final
49
+ // path: an invalid reader or failed writer must not remove another one's repair.
50
+ await rename(temporary, file);
51
+ await syncDirectory(dir);
52
+ } finally {
53
+ await handle?.close();
54
+ await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Prepare an instance-owned, immutable model snapshot before Transformers sees it.
60
+ * `artifacts` is a trusted manifest of filename -> { size, sha256 }, pinned with
61
+ * `model` and its commit `revision`. The hashes are the completion markers: legacy
62
+ * complete files work offline, partial/corrupt files are misses, and SIGKILL leaves
63
+ * only ignored unique .partial files. No lock or mutable sidecar can become stale.
64
+ * Returns a local directory; inference must use it with remote access disabled.
65
+ */
66
+ export async function prepareModelCache({ yeaftDir, model, revision, artifacts, allowDownload = true }) {
67
+ if (!/^[\w.-]+\/[\w.-]+$/.test(model) || model.split('/').some(p => p === '.' || p === '..') || !/^[a-f0-9]{40}$/.test(revision)) {
68
+ throw new Error('invalid_model_identity');
69
+ }
70
+ const dir = path.resolve(yeaftDir, 'person', 'models', model, revision);
71
+ for (const [name, expected] of Object.entries(artifacts)) {
72
+ if (!name.split('/').every(p => /^[\w.-]+$/.test(p) && p !== '.' && p !== '..') ||
73
+ !Number.isSafeInteger(expected.size) || expected.size <= 0 || !/^[a-f0-9]{64}$/.test(expected.sha256)) {
74
+ throw new Error('invalid_model_artifact');
75
+ }
76
+ const file = path.join(dir, name);
77
+ if (await isComplete(file, expected)) continue;
78
+ if (!allowDownload) throw new Error('model_cache_incomplete');
79
+ await publish(file, `https://huggingface.co/${model}/resolve/${revision}/${name}`, expected);
80
+ }
81
+ return dir;
82
+ }
@@ -0,0 +1,54 @@
1
+ import { prepareModelCache } from './local-memory-cache.js';
2
+
3
+ export const LOCAL_MODEL = 'Xenova/multilingual-e5-small';
4
+ export const LOCAL_REVISION = '761b726dd34fb83930e26aab4e9ac3899aa1fa78';
5
+ // SHA-256 of the exact revision's files (LFS oid for tokenizer/ONNX). Update this
6
+ // manifest with LOCAL_REVISION, never from mutable download response metadata.
7
+ export const LOCAL_ARTIFACTS = Object.freeze({
8
+ 'config.json': Object.freeze({ size: 658, sha256: 'cb99455288675345e1a4f411438d5d0adbba5fbd3a67ea4fb03c015433b996c1' }),
9
+ 'tokenizer_config.json': Object.freeze({ size: 443, sha256: 'a1d6bc8734a6f635dc158508bef000f8e2e5a759c7d92f984b2c86e5ff53425b' }),
10
+ 'tokenizer.json': Object.freeze({ size: 17082730, sha256: '0b44a9d7b51c3c62626640cda0e2c2f70fdacdc25bbbd68038369d14ebdf4c39' }),
11
+ 'onnx/model_quantized.onnx': Object.freeze({ size: 118308185, sha256: 'f80102d3f2a1229f387d3c81909990d8945513e347b0eab049f7de3c6f98c193' }),
12
+ });
13
+ export const VECTOR_DIMENSION = 384;
14
+ export const CHUNK_SIZE = 320;
15
+ export const CHUNK_OVERLAP = 64;
16
+ // Version the complete space, not just the model name. A change invalidates derived chunks.
17
+ export const MODEL_FINGERPRINT = `${LOCAL_MODEL}@${LOCAL_REVISION}:q8:mean:l2:384:e5-prefix:codepoints-${CHUNK_SIZE}-${CHUNK_OVERLAP}:segmenter-v1`;
18
+
19
+ /** Worker-only lazy adapter. Only model artifacts use the network; text goes to local ONNX. */
20
+ export function createEmbedding({ yeaftDir, allowDownload = true, threads = 2 } = {}) {
21
+ let extractor;
22
+ async function load() {
23
+ if (extractor) return extractor;
24
+ const modelDir = await prepareModelCache({ yeaftDir, model: LOCAL_MODEL, revision: LOCAL_REVISION, artifacts: LOCAL_ARTIFACTS, allowDownload });
25
+ const { pipeline, env } = await import('@huggingface/transformers');
26
+ // Never let Transformers' non-atomic FileCache write model artifacts. The
27
+ // validated local snapshot is complete, including when restarting offline.
28
+ env.allowRemoteModels = false;
29
+ env.allowLocalModels = true;
30
+ env.localModelPath = '';
31
+ env.useBrowserCache = false;
32
+ env.useCustomCache = true;
33
+ env.customCache = { async match() {}, async put() { throw new Error('unexpected_model_cache_write'); } };
34
+ // 3.8.1 also uses this flag to pass the ONNX *path* to the native runtime.
35
+ // The custom cache above prevents its unsafe FileCache from being instantiated.
36
+ env.useFSCache = true;
37
+ extractor = await pipeline('feature-extraction', modelDir, {
38
+ revision: LOCAL_REVISION, dtype: 'q8', device: 'cpu',
39
+ local_files_only: true,
40
+ session_options: { intraOpNumThreads: Math.max(1, Math.min(4, threads)), interOpNumThreads: 1 },
41
+ });
42
+ return extractor;
43
+ }
44
+ return {
45
+ async embed(texts, type) {
46
+ const model = await load();
47
+ const output = await model(texts.map(text => `${type === 'query' ? 'query' : 'passage'}: ${text}`), {
48
+ pooling: 'mean', normalize: true, truncation: true, max_length: 512,
49
+ });
50
+ return output.tolist();
51
+ },
52
+ async close() { if (extractor) await extractor.dispose(); extractor = null; },
53
+ };
54
+ }
@@ -0,0 +1,40 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { createEmbedding } from './local-memory-embedding.js';
3
+
4
+ // ONNX's native addon is not reliably reloadable in replacement Node threads.
5
+ // Keep it in this disposable process; SQLite/tokenization/ranking use a worker
6
+ // thread. The Agent supervises the whole process, including blocked native work.
7
+ let worker;
8
+ let embedder;
9
+ let closing = false;
10
+ process.on('message', message => {
11
+ if (message.init) {
12
+ if (worker) return;
13
+ const options = message.init;
14
+ worker = new Worker(new URL('./local-memory-worker.js', import.meta.url), { workerData: options });
15
+ worker.on('message', async response => {
16
+ if (response.embeddingRequest) {
17
+ const { id, texts, type } = response.embeddingRequest;
18
+ try {
19
+ embedder ??= createEmbedding({ ...options.embedding, yeaftDir: options.yeaftDir });
20
+ const value = await embedder.embed(texts, type);
21
+ worker.postMessage({ embeddingResult: { id, value } });
22
+ } catch {
23
+ worker.postMessage({ embeddingResult: { id, error: true } });
24
+ }
25
+ } else if (response.closed) {
26
+ closing = true;
27
+ try { await embedder?.close(); }
28
+ finally { process.disconnect(); }
29
+ } else {
30
+ process.send?.(response);
31
+ }
32
+ });
33
+ worker.on('error', () => process.exit(1));
34
+ worker.on('exit', code => { if (!closing || code) process.exit(1); });
35
+ return;
36
+ }
37
+ worker?.postMessage(message);
38
+ });
39
+ // Agent crash/IPC teardown must not leave the model process or its thread alive.
40
+ process.on('disconnect', () => process.exit(0));