@aiwuk/team 0.0.0-stage → 5.0.8

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.
Files changed (117) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/LICENSE +21 -0
  3. package/README.md +149 -2
  4. package/README.zh-CN.md +150 -0
  5. package/access.js +93 -0
  6. package/activity-summary.js +36 -0
  7. package/allocation.js +34 -0
  8. package/board-dispatch.js +141 -0
  9. package/brand.js +41 -0
  10. package/client/00-core-zh.js +284 -0
  11. package/client/01-i18n-en.js +265 -0
  12. package/client/02-styles.js +336 -0
  13. package/client/02b-stage-styles.js +65 -0
  14. package/client/03-shared.js +82 -0
  15. package/client/03b-collapsible-list.js +34 -0
  16. package/client/03c-config-panel.js +605 -0
  17. package/client/04-tool-cards.js +110 -0
  18. package/client/04a-dispatch-confirm.js +162 -0
  19. package/client/04b-more-cards.js +78 -0
  20. package/client/04c-model-picker.js +68 -0
  21. package/client/04d-confirm-dock.js +40 -0
  22. package/client/05-policy.js +217 -0
  23. package/client/06-usage.js +195 -0
  24. package/client/06b-model-source.js +67 -0
  25. package/client/07-board-parts.js +312 -0
  26. package/client/07a-role-theme.js +25 -0
  27. package/client/08-artifacts-report.js +375 -0
  28. package/client/08a-task-control.js +126 -0
  29. package/client/08b-stage-sprites.js +97 -0
  30. package/client/08c-stage-colors.js +55 -0
  31. package/client/08c-stage-scene.js +238 -0
  32. package/client/08d-stage-adapter.js +65 -0
  33. package/client/08e-stage-activity.js +83 -0
  34. package/client/09-team-board.js +181 -0
  35. package/client/09b-role-view.js +95 -0
  36. package/client/09c-model-tabs.js +20 -0
  37. package/client/10-overview.js +364 -0
  38. package/client/10b-onboarding.js +121 -0
  39. package/client/11-register.js +249 -0
  40. package/client.js +5053 -0
  41. package/config-projection.js +40 -0
  42. package/config.js +411 -0
  43. package/cordis.patch.yml +110 -0
  44. package/defaults.js +222 -0
  45. package/dispatch-confirm.js +293 -0
  46. package/dispatch-routes-store.js +31 -0
  47. package/failover.js +85 -0
  48. package/file-limits.js +119 -0
  49. package/free-worker.mjs +125 -0
  50. package/http-guard.js +14 -0
  51. package/i18n/roles.en.json +23 -0
  52. package/i18n/roles.zh.json +23 -0
  53. package/i18n/server.en.json +659 -0
  54. package/i18n/server.zh.json +659 -0
  55. package/i18n.js +34 -0
  56. package/icon.svg +7 -0
  57. package/index.js +113 -0
  58. package/integrations-config.js +301 -0
  59. package/integrations-messages.js +62 -0
  60. package/integrations.js +17 -0
  61. package/lead-route.js +30 -0
  62. package/locale/en.json +6 -0
  63. package/locale/zh.json +6 -0
  64. package/long-context.js +30 -0
  65. package/managed-config-route.js +49 -0
  66. package/managed-ref.js +38 -0
  67. package/managed-resolve.js +47 -0
  68. package/member-models.js +84 -0
  69. package/member-task-control.js +242 -0
  70. package/memory-backends.js +89 -0
  71. package/memory-embed.js +70 -0
  72. package/memory-hub.js +140 -0
  73. package/memory-openviking.js +101 -0
  74. package/memory-redact.js +34 -0
  75. package/memory-store.js +270 -0
  76. package/memory-sync-queue.js +28 -0
  77. package/model-policy.js +85 -0
  78. package/model-prefs.js +104 -0
  79. package/observability-api.js +76 -0
  80. package/observability-projection.js +116 -0
  81. package/onboarding.js +47 -0
  82. package/opencode.js +74 -0
  83. package/package.json +155 -4
  84. package/plan-gate.js +92 -0
  85. package/preset-guard.js +47 -0
  86. package/request-evidence.js +41 -0
  87. package/roles.js +105 -0
  88. package/roster-defaults.js +70 -0
  89. package/route-health.js +39 -0
  90. package/runtime-observability.js +78 -0
  91. package/runtime-view.js +21 -0
  92. package/scripts/memory-cli.mjs +138 -0
  93. package/search-engines.js +119 -0
  94. package/search.js +164 -0
  95. package/session-policy.js +59 -0
  96. package/session-rotation.js +206 -0
  97. package/settings-schema.js +40 -0
  98. package/shared/managed-config.js +84 -0
  99. package/single-model.js +44 -0
  100. package/skills/project-role-theme/SKILL.md +46 -0
  101. package/task-bridge.js +32 -0
  102. package/task-control-api.js +36 -0
  103. package/task-control-audit.js +60 -0
  104. package/team.js +101 -0
  105. package/teammate-context.js +120 -0
  106. package/teammate-wait.js +18 -0
  107. package/template.js +46 -0
  108. package/theme-plugin.js +172 -0
  109. package/theme.js +115 -0
  110. package/token-discipline.js +116 -0
  111. package/token-metrics.js +37 -0
  112. package/tool-names.js +10 -0
  113. package/tool-schema.js +23 -0
  114. package/transcribe.js +104 -0
  115. package/ui.js +113 -0
  116. package/usage.js +96 -0
  117. package/workflow.js +246 -0
@@ -0,0 +1,70 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Embeddings for the memory hub over an OpenAI-compatible embeddings endpoint. Everything about the
3
+ // endpoint is configuration (integrations.embeddings): base URL, path, extra headers, model, and the optional
4
+ // provider / dimensions / task fields some gateways (e.g. a Jina-backed one) accept. Nothing is assumed.
5
+ // Environment: EMBEDDING_BASE_URL / EMBEDDING_API_KEY / EMBEDDING_MODEL / EMBEDDING_DIMENSIONS (legacy
6
+ // CPA_EMBED_* names are still read when the standard ones are unset; see integrations-config.js).
7
+ // Any failure returns undefined: the hub then uses full-text search and embeds missing vectors later.
8
+ import { keyOf, refreshCredentials, resolveIntegrations } from './integrations-config.js';
9
+ const STATUS_HINT = { 400: modelText('memory-embed.267'), 401: modelText('memory-embed.268'), 403: modelText('memory-embed.269'), 404: modelText('memory-embed.270'), 429: modelText('memory-embed.271'), 502: modelText('memory-embed.272'), 504: modelText('memory-embed.273') };
10
+ /** Embeddings config and key as resolved now. */
11
+ export function embedSettings(resolved = resolveIntegrations()) {
12
+ return { config: resolved.effective.embeddings, key: keyOf('embeddings', resolved), keyEnv: resolved.keys.embeddings.env };
13
+ }
14
+ /** Vectors are only comparable within one model and dimension count; this tags them. */
15
+ export const vectorKey = config => `${config.model}@${config.dimensions ?? 'default'}`;
16
+ function missing(settings) {
17
+ const { config, key, keyEnv } = settings;
18
+ const gaps = [!config.baseUrl && 'embeddings.baseUrl', !config.model && 'embeddings.model', !key && modelText('memory-embed.274', { 0: keyEnv })].filter(Boolean);
19
+ return gaps.length ? modelText('memory-embed.275', { 0: gaps.join('、') }) : null;
20
+ }
21
+ /**
22
+ * `settings` is a function returning { config, key, keyEnv } (re-read on every call, so edits apply at once)
23
+ * or a fixed object. `embed(texts, purpose)` takes purpose 'passage' (stored) or 'query' (searching).
24
+ */
25
+ export function createEmbedder({ settings = embedSettings, fetcher = fetch } = {}) {
26
+ const current = () => { try { return typeof settings === 'function' ? settings() : settings; } catch (error) { return { error: error.message }; } };
27
+ let lastError = null;
28
+ return {
29
+ get available() { const s = current(); return !s.error && !missing(s); },
30
+ get lastError() { const s = current(); return s.error ?? missing(s) ?? lastError; },
31
+ get vectorKey() { const s = current(); return s.error || missing(s) ? null : vectorKey(s.config); },
32
+ async embed(texts, purpose = 'passage') {
33
+ if (settings === embedSettings) await refreshCredentials().catch(() => {});
34
+ const s = current();
35
+ if (s.error || missing(s) || !texts.length) return undefined;
36
+ const { config, key } = s;
37
+ const task = purpose === 'query' ? config.queryTask : config.passageTask;
38
+ const vectors = [];
39
+ for (let i = 0; i < texts.length; i += config.batch) {
40
+ const input = texts.slice(i, i + config.batch);
41
+ let response;
42
+ try {
43
+ response = await fetcher(`${config.baseUrl}${config.path}`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(config.timeoutMs),
44
+ headers: { ...config.headers, authorization: `Bearer ${key}`, 'content-type': 'application/json' },
45
+ body: JSON.stringify({ model: config.model, input, ...(config.provider ? { provider: config.provider } : {}),
46
+ ...(config.dimensions ? { dimensions: config.dimensions } : {}), ...(task ? { task } : {}) }) });
47
+ } catch (error) { lastError = modelText('memory-embed.276', { 0: error.message ?? error }); return undefined; }
48
+ if (!response.ok) { lastError = modelText('memory-embed.277', { 0: response.status, 1: STATUS_HINT[response.status] ? `(${STATUS_HINT[response.status]})` : '' }); return undefined; }
49
+ let body;
50
+ try { body = await response.json(); } catch { lastError = modelText('memory-embed.278'); return undefined; }
51
+ const rows = Array.isArray(body?.data) ? [...body.data].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) : [];
52
+ // A 200 with a status page or route list instead of vectors is a failure, not a result.
53
+ if (rows.length !== input.length || rows.some(r => !Array.isArray(r.embedding) || !r.embedding.length)) { lastError = modelText('memory-embed.279'); return undefined; }
54
+ vectors.push(...rows.map(r => normalize(r.embedding)));
55
+ }
56
+ lastError = null;
57
+ return { model: vectorKey(config), dimensions: vectors[0]?.length, vectors };
58
+ },
59
+ };
60
+ }
61
+ export function normalize(v) {
62
+ let n = 0; for (const x of v) n += x * x;
63
+ n = Math.sqrt(n) || 1;
64
+ return v.map(x => x / n);
65
+ }
66
+ export function cosine(a, b) {
67
+ if (!a || !b || a.length !== b.length) return undefined;
68
+ let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i];
69
+ return s;
70
+ }
package/memory-hub.js ADDED
@@ -0,0 +1,140 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Memory hub: what the team learned (handoffs, accepted results, pitfalls, decisions) is kept per project,
3
+ // recalled into each member's runtime context when it receives work, and searchable on demand. Ingestion is
4
+ // automatic for handoffs and teammates' final reports; members can also write a memory explicitly.
5
+ // Recall is capped (MEMORY_LIMITS.injectChars) and only changes when the task changes, so it costs little
6
+ // context and does not resend the runtime snapshot every step.
7
+ import { createEmbedder } from './memory-embed.js';
8
+ import { createMemoryBackend } from './memory-backends.js';
9
+ import { MEMORY_KINDS, MEMORY_LIMITS, MEMORY_SCOPES, createMemoryCore, createMemoryStore } from './memory-store.js';
10
+ import { baseName } from './session-rotation.js';
11
+ import { params } from './tool-schema.js';
12
+ import { ownsCapabilityTeam } from './preset-guard.js';
13
+ const output = { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] };
14
+ const SCOPES = MEMORY_SCOPES;
15
+ const messageText = message => (Array.isArray(message?.content) ? message.content.map(c => c?.text ?? '').join('\n') : String(message?.text ?? message?.content ?? '')).trim();
16
+ export function createMemoryHub(ctx, team, { store = createMemoryStore(team.root), embedder = createEmbedder(), backend = createMemoryBackend({ label: modelText('memory-hub.280') }), secrets = () => [] } = {}) {
17
+ const recall = new Map(); // agent id → { query, block }
18
+ const reports = new Map(); // `${session}:${callId}` → text of a pending teammate report
19
+ const identity = agent => {
20
+ const m = ctx.agentTeams.tryMembership(agent);
21
+ if (!m || !ownsCapabilityTeam(ctx, agent)) return undefined;
22
+ return { project: team.pinned(agent) ?? team.pinned(m.root ?? agent), role: m.role === 'lead' ? 'lead' : baseName(m.name), session: agent.id, lead: m.role === 'lead', leadId: (m.root ?? agent).id, name: m.name ?? 'lead' };
23
+ };
24
+ // Redaction, embedding, scope mapping, write + shared-backend sync and use counting are shared with @aiwuk/memory.
25
+ const core = createMemoryCore({ store, embedder, backend, secrets });
26
+ const embedOne = (text, purpose) => core.embed(text, purpose);
27
+ async function backfill(project) {
28
+ if (!embedder.available) return;
29
+ const missing = await store.unembedded(project, 16, embedder.vectorKey);
30
+ if (!missing.length) return;
31
+ const out = await embedder.embed(missing.map(m => m.text), 'passage');
32
+ if (out) for (let i = 0; i < missing.length; i++) await store.setVector(missing[i], out.vectors[i], out.model);
33
+ }
34
+ const hub = {
35
+ async write(who, { kind, text, scope = 'role', tags, key }) {
36
+ if (!who?.project) throw new Error(modelText('memory-hub.281'));
37
+ if (!SCOPES.includes(scope)) throw new Error(modelText('memory-hub.282', { 0: SCOPES.join(' / ') }));
38
+ if (scope === 'global' && !who.lead) throw new Error(modelText('memory-hub.283'));
39
+ // Secrets never enter memory: it is recalled into other members' prompts and may leave this machine.
40
+ // Session-scoped notes stay local; everything else also becomes shared long-term memory.
41
+ const saved = await core.write(who, { kind, text, scope, project: who.project, key: key ? String(key).slice(0, 120) : null,
42
+ tags: Array.isArray(tags) ? tags.map(String).slice(0, 10) : [], source: { member: who.name, session: who.session, lead: who.leadId, at: Date.now() } });
43
+ return saved;
44
+ },
45
+ async search(who, { query, k = 6, kinds }) {
46
+ // The query goes to the embedding service and the shared backend, so it is filtered like a stored memory.
47
+ const q = (await core.clean(String(query ?? '').slice(0, 4000))).slice(0, 2000);
48
+ const limit = Math.min(Math.max(1, k), 20);
49
+ const [embedded, shared] = await Promise.all([q ? embedOne(q, 'query') : null, kinds ? [] : backend.recall(q)]);
50
+ void backfill(who.project).catch(() => {});
51
+ const local = await store.search({ project: who.project, role: who.lead ? undefined : who.role, session: who.session, query: q, vector: embedded?.vector, vectorModel: embedded?.model, k: limit, kinds });
52
+ // Team memories first (scoped to this project/role), then shared long-term ones not already covered.
53
+ const seen = new Set(local.map(r => r.memory.text));
54
+ const results = []; let chars = 0;
55
+ for (const row of [...local, ...shared.filter(r => !seen.has(r.memory.text))]) {
56
+ const size = JSON.stringify(row.memory.text).length;
57
+ if (results.length >= limit || chars + size > 12000) continue;
58
+ results.push(row); chars += size;
59
+ }
60
+ return { results, mode: embedded ? 'hybrid' : 'text', embedError: embedded ? null : embedder.lastError, ...(backend.enabled ? { shared: { backend: backend.name, count: shared.length, error: backend.lastError } } : {}) };
61
+ },
62
+ backend,
63
+ /** Called by rotation after a member writes its handoff. */
64
+ async ingestHandoff(h) {
65
+ const who = { project: team.pinned(ctx.agents.get(h.session)) ?? undefined, role: h.role, session: h.session, lead: false, leadId: h.lead, name: h.member };
66
+ if (!who.project) return;
67
+ const w = h.written ?? {};
68
+ if (w.done || w.remaining) await hub.write(who, { kind: 'handoff', scope: 'role', key: `handoff:${h.role}`, text: modelText('memory-hub.284', { 0: h.member, 1: w.done || '—', 2: w.remaining || '—', 3: w.verify ? modelText('memory-hub.285', { 0: w.verify }) : '' }) });
69
+ if (w.pitfalls) await hub.write(who, { kind: 'pitfall', scope: 'project', text: modelText('memory-hub.286', { 0: h.role, 1: w.pitfalls }) });
70
+ },
71
+ /** Memory block for a member's runtime context; refreshed when its task text changes. */
72
+ async refreshRecall(agent, query) {
73
+ const who = identity(agent);
74
+ if (!who?.project || !query) return;
75
+ const prior = recall.get(agent.id);
76
+ if (prior?.query === query) return;
77
+ const { results } = await hub.search(who, { query, k: 8 });
78
+ const block = store.format(results, MEMORY_LIMITS.injectChars);
79
+ recall.set(agent.id, { query, block, ids: results.map(r => r.memory.id) });
80
+ await core.markUsed(results.map(r => r.memory)); // backend hits are not in the store
81
+ },
82
+ recallText(agent) {
83
+ const r = recall.get(agent.id);
84
+ return r?.block ? modelText('memory-hub.287', { 0: r.block }) : '';
85
+ },
86
+ register() {
87
+ ctx.on('agent/created', ({ agent }) => {
88
+ if (!ownsCapabilityTeam(ctx, agent) || !ctx.agentTeams.tryMembership(agent)) return;
89
+ agent.ctx.systemPrompt.context({ name: 'capability-team:memory', order: 97, text: () => hub.recallText(agent) });
90
+ });
91
+ ctx.on('agent/inbox/claimed', ({ agent, message }) => {
92
+ const text = messageText(message);
93
+ if (text.length >= 8 && ownsCapabilityTeam(ctx, agent) && ctx.agentTeams.tryMembership(agent)) void hub.refreshRecall(agent, text.slice(0, 2000)).catch(() => {});
94
+ });
95
+ // A teammate's successful final report to the Lead becomes a 'result' memory of its role.
96
+ try {
97
+ ctx.on('session/event', (session, event) => {
98
+ const agent = session?.id && ctx.agents.get(session.id);
99
+ if (!ownsCapabilityTeam(ctx, agent) || ctx.agentTeams.tryMembership(agent)?.role !== 'teammate') return;
100
+ const d = event?.data ?? {};
101
+ if (event.type === 'tool/call' && d.name === 'send_message') {
102
+ let args = {}; try { args = typeof d.arguments === 'string' ? JSON.parse(d.arguments) : d.arguments ?? {}; } catch { return; }
103
+ if (args.target === 'lead' && String(args.message ?? '').length >= 200) reports.set(`${session.id}:${d.callId}`, String(args.message));
104
+ } else if (event.type === 'tool/result') {
105
+ const key = `${session.id}:${d.message?.toolCallId}`;
106
+ const text = reports.get(key);
107
+ if (text === undefined) return;
108
+ reports.delete(key);
109
+ if (!d.message?.isError) void hub.write(identity(agent), { kind: 'result', scope: 'role', text }).catch(() => {});
110
+ }
111
+ });
112
+ } catch { /* host without session observers: explicit writes still work */ }
113
+ ctx.effect(() => ctx.tools.register({ name: 'capability_memory_search', output,
114
+ description: modelText('memory-hub.288'),
115
+ parameters: params({ query: { type: 'string', description: modelText('memory-hub.289') }, k: { type: 'integer', description: modelText('memory-hub.290') },
116
+ kinds: { type: 'array', items: { type: 'string', enum: MEMORY_KINDS }, description: modelText('memory-hub.291') } }, ['query']),
117
+ async execute(args, exec) {
118
+ const who = identity(exec.agent);
119
+ if (!who) throw new Error(modelText('memory-hub.292'));
120
+ const { results, mode, embedError, shared } = await hub.search(who, args);
121
+ return JSON.stringify({ mode, ...(embedError ? { note: modelText('memory-hub.293', { 0: embedError }) } : {}),
122
+ ...(shared ? { shared: shared.error ? modelText('memory-hub.294', { 0: shared.backend, 1: shared.error }) : modelText('memory-hub.295', { 0: shared.count, 1: shared.backend }) } : {}),
123
+ results: results.map(r => ({ id: r.memory.id, kind: r.memory.kind, scope: r.memory.scope.kind, text: r.memory.text, via: r.via, updatedAt: r.memory.updatedAt })) });
124
+ },
125
+ }));
126
+ ctx.effect(() => ctx.tools.register({ name: 'capability_memory_write', output,
127
+ description: modelText('memory-hub.296'),
128
+ parameters: params({ kind: { type: 'string', enum: ['pitfall', 'decision', 'note'] }, text: { type: 'string' }, scope: { type: 'string', enum: SCOPES },
129
+ key: { type: 'string', description: modelText('memory-hub.297') }, tags: { type: 'array', items: { type: 'string' } } }, ['kind', 'text']),
130
+ async execute(args, exec) {
131
+ const { memory, merged, syncError } = await hub.write(identity(exec.agent), args);
132
+ return JSON.stringify({ saved: true, merged, ...(syncError ? { syncWarning: syncError } : {}), id: memory.id, kind: memory.kind, scope: memory.scope.kind, embedded: !!memory.vector });
133
+ },
134
+ }));
135
+ // Plugin reload or shutdown: send what is still queued for the shared long-term memory.
136
+ ctx.effect(() => () => { void backend.flush().catch(() => {}); });
137
+ },
138
+ };
139
+ return hub;
140
+ }
@@ -0,0 +1,101 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // OpenViking backend for the memory hub (integrations.memory.backend = 'openviking').
3
+ // The team's own store stays the source for role/session-scoped recall; every memory is also sent to the
4
+ // OpenViking server, which extracts and indexes it as long-term memory shared by every agent and session that
5
+ // uses that server (DSH, Codex, OpenClaw...). Recall merges OpenViking's context search into team results.
6
+ // The server is optional at run time: when it is down, writes stay queued for the next flush and recall falls
7
+ // back to the local store, so a stopped server never blocks a member.
8
+ import { keyOf, refreshCredentials, resolveIntegrations } from './integrations-config.js';
9
+ import { createSyncQueue } from './memory-sync-queue.js';
10
+ import { defaultRoot } from './theme.js';
11
+ import { join } from 'node:path';
12
+ export const OV_SESSION_PREFIX = 'capability-team';
13
+ const FLUSH_MS = 20000;
14
+ const MAX_QUEUE = 200;
15
+ const MAX_BATCH = 40;
16
+ export function memorySettings(resolved = resolveIntegrations()) {
17
+ return { config: resolved.effective.memory, key: keyOf('memory', resolved) };
18
+ }
19
+ // `label` names the caller (e.g. 'Capability Team' for the team plugin, 'Global Memory' for global-memory) at the start of
20
+ // every message sent upstream; the default is neutral (no caller name).
21
+ export function createOpenViking({ settings, queuePath, label = '', warn = () => {}, fetcher = fetch, now = Date.now, schedule = (fn, ms) => { const t = setTimeout(fn, ms); t.unref?.(); return t; } } = {}) {
22
+ const current = () => settings ?? memorySettings();
23
+ const prefix = String(label ?? '').replace(/[【】\r\n]/g, '').trim().slice(0, 40);
24
+ const queue = createSyncQueue(queuePath ?? (settings ? null : join(defaultRoot(), 'memory', 'openviking-queue.json')), { max: MAX_QUEUE, warn });
25
+ let timer = null;
26
+ let flushing = null;
27
+ const api = {
28
+ name: 'openviking',
29
+ lastError: null,
30
+ get enabled() { const { config } = current(); return config.backend === 'openviking' && !!config.url; },
31
+ async request(method, path, body, timeoutMs) {
32
+ if (!settings) await refreshCredentials().catch(() => {});
33
+ const { config, key } = current();
34
+ const headers = { 'Content-Type': 'application/json', ...(key ? { 'X-API-Key': key } : {}),
35
+ ...(config.account ? { 'X-OpenViking-Account': config.account } : {}), ...(config.user ? { 'X-OpenViking-User': config.user } : {}) };
36
+ const response = await fetcher(`${config.url}/api/v1${path}`, { method, headers, redirect: 'error',
37
+ body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs ?? config.timeoutMs) });
38
+ const text = await response.text();
39
+ if (!response.ok) throw new Error(`OpenViking HTTP ${response.status}${response.status === 401 || response.status === 403 ? modelText('memory-openviking.298') : ''}`);
40
+ let data; try { data = JSON.parse(text); } catch { throw new Error(modelText('memory-openviking.299')); }
41
+ if (data?.status && data.status !== 'ok') throw new Error(modelText('memory-openviking.300', { 0: String(data.status).slice(0, 40) }));
42
+ return data?.result ?? data;
43
+ },
44
+ /** Queue one memory for the next commit; the text is already redacted by the hub. */
45
+ enqueue(memory, who) {
46
+ if (!api.enabled) return false;
47
+ // Only the project's folder name goes upstream: the full path exposes this machine's layout and user name.
48
+ const projectName = String(who?.project ?? '').split(/[\\/]/).filter(Boolean).pop();
49
+ const where = [projectName ? modelText('memory-openviking.301', { 0: projectName }) : null, memory.scope?.kind === 'role' ? modelText('memory-openviking.302', { 0: memory.scope.role }) : null].filter(Boolean).join(' · ');
50
+ queue.push({ role: 'user', content: `【${prefix}${memory.kind === 'pitfall' ? modelText('memory-openviking.303') : memory.kind === 'decision' ? modelText('memory-openviking.304') : modelText('memory-openviking.305')}${where ? ` · ${where}` : ''}】${memory.text}` });
51
+ api.lastError = null;
52
+ if (!timer) timer = schedule(() => { timer = null; void api.flush().catch(() => {}); }, FLUSH_MS);
53
+ return true;
54
+ },
55
+ /** Send queued memories as one session and commit it for extraction; failures keep them queued. */
56
+ async flush() {
57
+ if (flushing) return flushing;
58
+ if (!queue.size() || !api.enabled) return { sent: 0 };
59
+ flushing = (async () => {
60
+ const batch = queue.peek(MAX_BATCH);
61
+ const sid = `${OV_SESSION_PREFIX}-${now()}`;
62
+ try {
63
+ await api.request('POST', '/sessions', { session_id: sid });
64
+ for (const { role, content } of batch) await api.request('POST', `/sessions/${sid}/messages`, { role, content });
65
+ await api.request('POST', `/sessions/${sid}/commit`, {});
66
+ queue.ack(batch);
67
+ api.lastError = null;
68
+ return { sent: batch.length, session: sid };
69
+ } catch (error) { api.lastError = error.message; throw error; }
70
+ finally { flushing = null; if (queue.size() && !timer) timer = schedule(() => { timer = null; void api.flush().catch(() => {}); }, FLUSH_MS); }
71
+ })();
72
+ return flushing;
73
+ },
74
+ /** Long-term memories relevant to `query`, as hub results; [] when disabled or unreachable. */
75
+ async recall(query, limit) {
76
+ if (!api.enabled || !query) return [];
77
+ const { config } = current();
78
+ try {
79
+ const result = await api.request('POST', '/search/search', { query: String(query).slice(0, 2000), mode: 'context', limit: limit ?? config.recallLimit });
80
+ api.lastError = null;
81
+ return (Array.isArray(result?.entries) ? result.entries : []).filter(e => typeof e?.text === 'string' && e.text.trim() && !queue.revoked().some(t => e.text.includes(t))).slice(0, limit ?? config.recallLimit)
82
+ .map(e => ({ score: Number(e.score) || 0, via: ['openviking'], memory: { id: e.uri, kind: 'note', scope: { kind: 'openviking', category: e.category }, text: e.text.trim().slice(0, 1200), uri: e.uri } }));
83
+ } catch (error) { api.lastError = error.message; return []; }
84
+ },
85
+ async status() {
86
+ if (!api.enabled) return { enabled: false };
87
+ const { config } = current();
88
+ const response = await fetcher(`${config.url}/health`, { signal: AbortSignal.timeout(5000), redirect: 'error' });
89
+ return { enabled: true, healthy: response.ok, queued: queue.size(), lastError: api.lastError, ...(response.ok ? await response.json() : {}) };
90
+ },
91
+ revoke(memories) {
92
+ const texts = memories.map(m => m.text).filter(Boolean);
93
+ queue.revoke(texts);
94
+ for (const text of texts) queue.push({ role: 'user', content: modelText('memory-openviking.306', { 0: text }) });
95
+ if (!timer) timer = schedule(() => { timer = null; void api.flush().catch(() => {}); }, FLUSH_MS);
96
+ },
97
+ pending: () => queue.size(),
98
+ };
99
+ if (queue.size()) timer = schedule(() => { timer = null; void api.flush().catch(() => {}); }, FLUSH_MS);
100
+ return api;
101
+ }
@@ -0,0 +1,34 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Secret filter applied to every memory before it is stored locally or sent to an external memory service.
3
+ // Memories are recalled into other members' prompts later, so a leaked token would spread; values are
4
+ // replaced with a marker instead of rejecting the whole memory, which usually still carries the useful lesson.
5
+ const PATTERNS = [
6
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
7
+ /\b(?:sk|pk|rk)-(?:[A-Za-z0-9_-]{2,16}-)?[A-Za-z0-9_-]{16,}\b/g,
8
+ /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/g,
9
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g,
10
+ /\bAKIA[0-9A-Z]{16}\b/g,
11
+ /\b(?:tvly|xai|gsk|glpat)-[A-Za-z0-9_-]{16,}\b/g,
12
+ /\b(?:jina|hf|npm)_[A-Za-z0-9_]{16,}\b/g,
13
+ /\bBSA[A-Za-z0-9_-]{20,}\b/g,
14
+ /\bAIza[0-9A-Za-z_-]{35}\b/g,
15
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
16
+ /\b(Bearer|Basic)\s+[A-Za-z0-9._~+\/=-]{16,}/gi,
17
+ /\b([a-z][a-z0-9+.-]*:\/\/)[^\s:@\/]+:[^\s@\/]+@/gi,
18
+ // Any name ending in one of these, so OPENVIKING_API_KEY=… and GH_TOKEN=… match as well as key=…
19
+ /\b((?:[\w-]*(?:key|token|secret|password|passwd|pwd)|auth)["']?\s*[:=]\s*["']?)[^\s"',;]{8,}/gi,
20
+ ];
21
+ export const REDACTED = modelText('memory-redact.307');
22
+ /** Text with likely secrets replaced; `extra` are exact values (e.g. keys from the credential store). */
23
+ export function redactSecrets(text, extra = []) {
24
+ let out = String(text ?? '');
25
+ for (const value of extra) if (typeof value === 'string' && value.length >= 8) out = out.split(value).join(REDACTED);
26
+ for (const re of PATTERNS) {
27
+ out = out.replace(re, (match, prefix) => {
28
+ if (/^(bearer|basic)$/i.test(prefix ?? '')) return `${prefix} ${REDACTED}`;
29
+ if (typeof prefix === 'string' && /[:=@]|:\/\/$/.test(prefix)) return prefix.endsWith('://') ? `${prefix}${REDACTED}@` : `${prefix}${REDACTED}`;
30
+ return REDACTED;
31
+ });
32
+ }
33
+ return out;
34
+ }
@@ -0,0 +1,270 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Memory store: one JSONL file per project plus a global file, a BM25 full-text index built in memory
3
+ // (Latin words + CJK character bigrams), optional vectors, and hybrid ranking by reciprocal rank fusion.
4
+ // Writes merge near-duplicates instead of piling them up; ranking decays with age and rises with use.
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { appendFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { cosine } from './memory-embed.js';
9
+ import { acquireLock } from './theme.js';
10
+ import { redactSecrets } from './memory-redact.js';
11
+ /** Several DSH processes (Desktop, `dsh web`, profiles) share the files: writes and compaction take a file lock. */
12
+ async function withFileLock(path, fn) {
13
+ let handle = null;
14
+ for (let attempt = 0; !handle; attempt++) {
15
+ try { handle = await acquireLock(`${path}.lock`); }
16
+ catch (error) { if (attempt >= 100 || ['EACCES', 'EPERM', 'ENOENT'].includes(error.code) && attempt >= 3) throw error; await new Promise(r => setTimeout(r, 30)); }
17
+ }
18
+ try { return await fn(); } finally { await handle.close().catch(() => {}); await unlink(`${path}.lock`).catch(() => {}); }
19
+ }
20
+ export const MEMORY_KINDS = ['handoff', 'result', 'pitfall', 'decision', 'note'];
21
+ export const MEMORY_LIMITS = { maxText: 2000, maxPerFile: 2000, halfLifeDays: 30, pruneDays: 180, duplicateCosine: 0.92, injectChars: 2400 };
22
+ const DAY = 86400000;
23
+ /** Tokens for BM25: lowercase Latin/digit words and CJK unigrams + bigrams. */
24
+ /** Superseded JSONL lines tolerated beyond twice the live count before a file is rewritten. */
25
+ const COMPACT_SLACK = 200;
26
+ export function tokenize(text) {
27
+ const out = [];
28
+ const s = String(text ?? '').toLowerCase();
29
+ for (const w of s.match(/[a-z0-9_][a-z0-9_.-]*/g) ?? []) out.push(w);
30
+ const cjk = s.match(/[\u3400-\u9fff\uf900-\ufaff]+/g) ?? [];
31
+ for (const run of cjk) { for (let i = 0; i < run.length; i++) { out.push(run[i]); if (i + 1 < run.length) out.push(run.slice(i, i + 2)); } }
32
+ return out;
33
+ }
34
+ const normText = text => String(text ?? '').replace(/\s+/g, ' ').trim();
35
+ const fileKey = project => (project ? createHash('sha256').update(project).digest('hex').slice(0, 32) : 'global');
36
+ /** Whether a memory is visible to a reader: its project, and role/session scopes when narrower. */
37
+ export function visible(m, { project, role, session }) {
38
+ if (m.supersededBy) return false;
39
+ if (m.scope.kind === 'global') return true;
40
+ if (m.scope.project !== project) return false;
41
+ if (m.scope.kind === 'role') return !role || m.scope.role === role;
42
+ if (m.scope.kind === 'session') return m.scope.session === session;
43
+ return true;
44
+ }
45
+ function bm25(docs, query) {
46
+ const q = [...new Set(tokenize(query))];
47
+ if (!q.length || !docs.length) return new Map();
48
+ const tf = docs.map(d => { const m = new Map(); for (const t of tokenize(d.text)) m.set(t, (m.get(t) ?? 0) + 1); return m; });
49
+ const lengths = tf.map(m => [...m.values()].reduce((a, b) => a + b, 0));
50
+ const avg = lengths.reduce((a, b) => a + b, 0) / docs.length || 1;
51
+ const scores = new Map();
52
+ for (const term of q) {
53
+ const df = tf.filter(m => m.has(term)).length;
54
+ if (!df) continue;
55
+ const idf = Math.log(1 + (docs.length - df + 0.5) / (df + 0.5));
56
+ tf.forEach((m, i) => {
57
+ const f = m.get(term); if (!f) return;
58
+ scores.set(docs[i].id, (scores.get(docs[i].id) ?? 0) + idf * (f * 2.2) / (f + 1.2 * (0.25 + 0.75 * lengths[i] / avg)));
59
+ });
60
+ }
61
+ return scores;
62
+ }
63
+ /** Hybrid rank: RRF over BM25 and cosine, times recency decay and a small boost for memories that helped before. */
64
+ export function rank(docs, query, queryVector, { now = Date.now(), halfLifeDays = MEMORY_LIMITS.halfLifeDays, vectorModel } = {}) {
65
+ const text = bm25(docs, query);
66
+ const byText = [...text.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
67
+ // Vectors from another model or dimension count are not comparable, even when the lengths match.
68
+ const byVec = queryVector ? docs.filter(d => !vectorModel || d.model === vectorModel).map(d => [d.id, cosine(d.vector, queryVector)]).filter(([, s]) => s !== undefined && s > 0.2).sort((a, b) => b[1] - a[1]).map(([id]) => id) : [];
69
+ const fused = new Map();
70
+ byText.forEach((id, i) => fused.set(id, (fused.get(id) ?? 0) + 1 / (60 + i)));
71
+ byVec.forEach((id, i) => fused.set(id, (fused.get(id) ?? 0) + 1 / (60 + i)));
72
+ return docs.filter(d => fused.has(d.id)).map(d => {
73
+ const age = Math.max(0, now - (d.updatedAt ?? d.createdAt)) / DAY;
74
+ const decay = Math.pow(0.5, age / halfLifeDays);
75
+ return { memory: d, score: fused.get(d.id) * (0.3 + 0.7 * decay) * (1 + Math.min(d.hits ?? 0, 10) * 0.05), via: [text.has(d.id) ? 'text' : null, byVec.includes(d.id) ? 'vector' : null].filter(Boolean) };
76
+ }).sort((a, b) => b.score - a.score);
77
+ }
78
+ export function createMemoryStore(root, { now = Date.now, limits = MEMORY_LIMITS } = {}) {
79
+ const dir = join(root, 'memory');
80
+ const cache = new Map(); // file key → memories
81
+ const seen = new Map(); // file key → size:mtime of the file as last read or written by this process
82
+ const lines = new Map(); // file key → JSONL lines in the file (live records plus superseded appends)
83
+ const pathOf = key => join(dir, `${key}.jsonl`);
84
+ const stamp = async key => { try { const s = await stat(pathOf(key)); return `${s.size}:${s.mtimeMs}`; } catch { return 'none'; } };
85
+ async function load(key) {
86
+ // Another process may have appended or compacted since: re-read when the file changed under us.
87
+ // Stamped before reading, so an append that lands during the read makes the next load read again.
88
+ const current = await stamp(key);
89
+ if (cache.has(key) && seen.get(key) === current) return cache.get(key);
90
+ let list = [];
91
+ try {
92
+ const latest = new Map();
93
+ let count = 0;
94
+ for (const line of (await readFile(pathOf(key), 'utf8')).split('\n')) {
95
+ if (!line.trim()) continue;
96
+ count++;
97
+ try { const m = JSON.parse(line); latest.set(m.id, m); } catch { /* a torn last line is skipped */ }
98
+ }
99
+ list = [...latest.values()].filter(m => !m.deleted);
100
+ lines.set(key, count);
101
+ } catch (error) { if (error.code !== 'ENOENT') throw error; lines.set(key, 0); }
102
+ const previous = cache.get(key);
103
+ if (previous) { previous.length = 0; previous.push(...list); list = previous; } // keep callers' array identity
104
+ cache.set(key, list);
105
+ seen.set(key, current);
106
+ return list;
107
+ }
108
+ async function persist(key, record) {
109
+ await mkdir(dir, { recursive: true });
110
+ await withFileLock(pathOf(key), async () => {
111
+ const before = await stamp(key);
112
+ await appendFile(pathOf(key), JSON.stringify(record) + '\n');
113
+ lines.set(key, (lines.get(key) ?? 0) + 1);
114
+ // Only our own append happened since the last read: the cache is still current.
115
+ if (seen.get(key) === before) seen.set(key, await stamp(key));
116
+ });
117
+ // Every update (hits on recall, merges, vectors) appends the whole record again; without this the file grows
118
+ // without bound even though the live set is capped. Rewrite once superseded lines outnumber live ones.
119
+ if ((lines.get(key) ?? 0) > 2 * (cache.get(key)?.length ?? 0) + COMPACT_SLACK) await compact(key);
120
+ }
121
+ /** Rewrite the file from its current content (re-read under the lock, so other processes' appends survive). */
122
+ async function compact(key, select = list => list) {
123
+ await mkdir(dir, { recursive: true });
124
+ return withFileLock(pathOf(key), async () => {
125
+ seen.delete(key);
126
+ const list = await load(key);
127
+ const keep = [...select(list)]; // a copy: `list` is cleared and refilled below
128
+ const temp = `${pathOf(key)}.${randomUUID()}.tmp`;
129
+ await writeFile(temp, keep.map(m => JSON.stringify(m)).join('\n') + (keep.length ? '\n' : ''));
130
+ await rename(temp, pathOf(key));
131
+ lines.set(key, keep.length);
132
+ list.length = 0; list.push(...keep);
133
+ seen.set(key, await stamp(key));
134
+ return keep;
135
+ });
136
+ }
137
+ const keyOf = scope => (scope.kind === 'global' ? 'global' : fileKey(scope.project));
138
+ // One operation at a time per store: a write adds its record to the cached list before appending it, so a reload
139
+ // in between would drop the record from the cache while the append then marks that cache as current. The record
140
+ // would stay on disk but be invisible to every search until the file next changed.
141
+ let tail = Promise.resolve();
142
+ const serial = fn => { const run = tail.then(fn); tail = run.catch(() => {}); return run; };
143
+ const raw = {
144
+ /** Insert, or merge into a near-duplicate in the same scope (same normalized text, or cosine ≥ threshold). */
145
+ async write({ scope, kind, text, tags = [], source = {}, vector = null, model = null, key: topic = null }) {
146
+ if (!MEMORY_KINDS.includes(kind)) throw new Error(modelText('memory-store.308', { 0: MEMORY_KINDS.join(' / ') }));
147
+ const body = normText(text).slice(0, limits.maxText);
148
+ if (body.length < 4) throw new Error(modelText('memory-store.309'));
149
+ if (scope.kind !== 'global' && !scope.project) throw new Error(modelText('memory-store.310'));
150
+ const fk = keyOf(scope);
151
+ const list = await load(fk);
152
+ const same = m => m.kind === kind && JSON.stringify(m.scope) === JSON.stringify(scope) && !m.supersededBy;
153
+ const at = now();
154
+ const superseded = [];
155
+ // A memory with the same topic key replaces the older ones (conflict update: newest wins).
156
+ const supersede = async keeper => {
157
+ if (!topic) return;
158
+ for (const old of list.filter(m => same(m) && m.key === topic && m.id !== keeper.id)) { superseded.push({ ...old }); old.supersededBy = keeper.id; old.updatedAt = at; await persist(fk, old); }
159
+ };
160
+ const dup = list.find(m => same(m) && (normText(m.text) === body || (vector && m.model === model && cosine(m.vector, vector) >= limits.duplicateCosine)));
161
+ if (dup) {
162
+ Object.assign(dup, { text: body.length >= dup.text.length ? body : dup.text, tags: [...new Set([...dup.tags, ...tags])], updatedAt: at, merged: (dup.merged ?? 0) + 1, ...(topic ? { key: topic } : {}), ...(vector ? { vector, model } : {}) });
163
+ await persist(fk, dup);
164
+ await supersede(dup);
165
+ return { memory: dup, merged: true, superseded };
166
+ }
167
+ const memory = { id: randomUUID(), scope, kind, text: body, tags: [...new Set(tags)], source, key: topic, vector, model, createdAt: at, updatedAt: at, hits: 0 };
168
+ list.push(memory);
169
+ await persist(fk, memory);
170
+ await supersede(memory);
171
+ if (list.length > limits.maxPerFile) await this.prune(scope);
172
+ return { memory, merged: false, superseded };
173
+ },
174
+ /** Visible memories for a reader, ranked for `query`. */
175
+ async search({ project, role, session, query, vector, vectorModel, k = 6, kinds }) {
176
+ const pool = [...await load('global'), ...(project ? await load(fileKey(project)) : [])]
177
+ .filter(m => visible(m, { project, role, session }) && (!kinds || kinds.includes(m.kind)));
178
+ return rank(pool, query, vector, { now: now(), halfLifeDays: limits.halfLifeDays, vectorModel }).slice(0, k);
179
+ },
180
+ async markUsed(memories) {
181
+ const at = now();
182
+ for (const m of memories) { m.hits = (m.hits ?? 0) + 1; m.lastUsedAt = at; await persist(keyOf(m.scope), m); }
183
+ },
184
+ /** Memories without a vector of the current model (for backfill once embeddings work or after a model change). */
185
+ async unembedded(project, limit = 64, vectorModel = null) {
186
+ return [...await load('global'), ...(project ? await load(fileKey(project)) : [])].filter(m => !m.supersededBy && (!m.vector || (vectorModel && m.model !== vectorModel))).slice(0, limit);
187
+ },
188
+ async setVector(m, vector, model) { m.vector = vector; m.model = model; await persist(keyOf(m.scope), m); },
189
+ /** Forget: drop superseded entries and unused old ones, keep the newest maxPerFile. */
190
+ async prune(scope) {
191
+ const cutoff = now() - limits.pruneDays * DAY;
192
+ let removed = 0;
193
+ await compact(keyOf(scope), list => {
194
+ const keep = list.filter(m => !m.supersededBy && !((m.hits ?? 0) === 0 && (m.updatedAt ?? m.createdAt) < cutoff))
195
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, limits.maxPerFile);
196
+ removed = list.length - keep.length;
197
+ return keep;
198
+ });
199
+ return removed;
200
+ },
201
+ async remove(scope, id) {
202
+ const fk = keyOf(scope); const list = await load(fk);
203
+ const i = list.findIndex(m => m.id === id); if (i < 0) return false;
204
+ const [m] = list.splice(i, 1); await persist(fk, { ...m, deleted: true }); return true;
205
+ },
206
+ /** Text block for automatic injection: newest-first ranked memories within a character budget. */
207
+ format(results, maxChars = limits.injectChars) {
208
+ const lines = []; let used = 0;
209
+ for (const { memory: m } of results) {
210
+ const line = `- [${m.kind}${m.scope.kind === 'role' ? `·${m.scope.role}` : ''}] ${m.text}`;
211
+ if (used + line.length + (lines.length ? 1 : 0) > maxChars) continue;
212
+ used += line.length + (lines.length ? 1 : 0); lines.push(line);
213
+ }
214
+ return lines.join('\n');
215
+ },
216
+ };
217
+ const store = { ...raw };
218
+ for (const name of ['write', 'search', 'markUsed', 'unembedded', 'setVector', 'prune', 'remove']) store[name] = (...args) => serial(() => raw[name](...args));
219
+ return store;
220
+ }
221
+
222
+ // Shared write/search pipeline of the team memory hub (capability_memory_*) and @aiwuk/memory (memory_*): secret
223
+ // redaction, embedding, scope mapping (including the team `role` scope), write plus shared-backend sync and use
224
+ // counting live here once. Tool contracts, result shapes and recall policy stay with the two callers; the storage
225
+ // format is untouched, so existing memory files stay readable.
226
+ export const MEMORY_SCOPES = Object.freeze(['project', 'role', 'session', 'global']);
227
+ /** Store scope of a write. `role` is the team scope: visible to the same role of the same project. */
228
+ export function storeScope(scope, { project, session, role }) {
229
+ if (scope === 'global') return { kind: 'global' };
230
+ if (scope === 'project') return { kind: 'project', project };
231
+ if (scope === 'session') return { kind: 'session', project, session };
232
+ if (scope === 'role') return { kind: 'role', project, role };
233
+ throw new Error(`Unknown memory scope: ${scope}`);
234
+ }
235
+ export function createMemoryCore({ store, embedder, backend, secrets = () => [] }) {
236
+ /** Redact known credential values and token shapes before text is stored, embedded, synced or recalled. */
237
+ const clean = async text => redactSecrets(String(text ?? ''), await Promise.resolve(secrets()).catch(() => []));
238
+ /** One embedding as { vector, model }, or null when the embedder is unavailable or fails. */
239
+ async function embed(text, purpose) {
240
+ try { const out = await embedder.embed([text], purpose); return out ? { vector: out.vectors[0], model: out.model } : null; } catch { return null; }
241
+ }
242
+ return {
243
+ clean,
244
+ embed,
245
+ /**
246
+ * Save one memory; `text` is redacted here, `key`/`tags` are prepared by the caller. Session scope never syncs.
247
+ * `sync` enables shared-backend sync, `vectorize` enables embedding. A sync failure is reported as `syncError`.
248
+ * Returns the store result plus { embedded, sync: 'local-only' | 'queued' | 'disabled' }.
249
+ */
250
+ async write(who, { kind, text, scope, key = null, tags = [], source, project = who.project, vectorize = true, sync = true }) {
251
+ const body = await clean(text);
252
+ const embedded = vectorize ? await embed(body, 'passage') : null;
253
+ const saved = await store.write({ scope: storeScope(scope, { project, session: who.session, role: who.role }), kind, text: body, tags, key,
254
+ vector: embedded?.vector ?? null, model: embedded?.model ?? null, ...(source ? { source } : {}) });
255
+ const result = { ...saved, embedded: !!embedded, sync: scope === 'session' ? 'local-only' : 'disabled' };
256
+ if (scope !== 'session' && sync) {
257
+ try {
258
+ if (saved.superseded?.length) await backend.revoke?.(saved.superseded);
259
+ if (!saved.merged || saved.superseded?.length) result.sync = (await backend.enqueue(saved.memory, who)) ? 'queued' : 'disabled';
260
+ } catch (error) { result.syncError = error?.message ?? String(error); }
261
+ }
262
+ return result;
263
+ },
264
+ /** Count a recall hit on the local memories among `memories`; shared-backend rows are not in the store. */
265
+ async markUsed(memories) {
266
+ const local = memories.filter(m => MEMORY_SCOPES.includes(m?.scope?.kind));
267
+ if (local.length) await store.markUsed(local);
268
+ },
269
+ };
270
+ }