@yeaft/webchat-agent 0.1.531 → 0.1.533
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.
- package/package.json +1 -1
- package/unify/engine.js +3 -0
- package/unify/memory/migrate-r5-to-r6.js +61 -0
- package/unify/memory/recall-r6.js +291 -0
- package/unify/memory/schema.js +166 -0
- package/unify/memory/shard-store.js +373 -0
- package/unify/prompts.js +32 -8
- package/unify/tasks/store.js +135 -0
- package/unify/tasks/summary.js +338 -0
- package/unify/tools/index.js +4 -0
- package/unify/tools/memory-trace.js +135 -0
- package/unify/tools/open-source-message.js +49 -0
- package/unify/tools/task-tools.js +85 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shard-store.js — task-334f R6 semantic-shard memory store (VP/task/user).
|
|
3
|
+
*
|
|
4
|
+
* This sits ON TOP OF 334o's `storage/shard-store.js` primitive. It adds:
|
|
5
|
+
* - R6 entry schema (shard / sourceRef / supersedes / supersededBy / authoredBy)
|
|
6
|
+
* - Frontmatter body serialisation (markdown-friendly, grep-able on disk)
|
|
7
|
+
* - Supersede chain management (Δ26.2 Phase B)
|
|
8
|
+
* - Atomic re-compression handoff (`memory-<shard>.md.compacting`)
|
|
9
|
+
* - Migration stub from legacy R5 `memory-NNN.md` → semantic shards (334i)
|
|
10
|
+
*
|
|
11
|
+
* Hard boundaries (task-334f guardrails):
|
|
12
|
+
* - does NOT touch 334o's jsonl-log layer
|
|
13
|
+
* - does NOT run dream extract / re-compression decisions (334g)
|
|
14
|
+
* - does NOT implement user-memory business logic (334l reuses this lib)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { openShardStore, writeAtomic } from '../storage/index.js';
|
|
20
|
+
import {
|
|
21
|
+
rebuildShardIndexFromDisk,
|
|
22
|
+
saveShardIndex,
|
|
23
|
+
} from '../storage/shard-index.js';
|
|
24
|
+
import {
|
|
25
|
+
buildShardSchema,
|
|
26
|
+
softCapFor,
|
|
27
|
+
PROJECT_DERIVE_THRESHOLD,
|
|
28
|
+
MAX_VP_SHARDS,
|
|
29
|
+
validateR6Entry,
|
|
30
|
+
AUTHORED_BY,
|
|
31
|
+
} from './schema.js';
|
|
32
|
+
|
|
33
|
+
const COMPACTING_SUFFIX = '.compacting';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Open (or create) an R6 memory shard store rooted at `dir`.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} dir filesystem directory (e.g. `~/.yeaft/memory/vp/<vpId>`)
|
|
39
|
+
* @param {'vp'|'task'|'user'} kind
|
|
40
|
+
* @param {{ extraShards?: string[] }} [opts]
|
|
41
|
+
* @returns {object} handle with put/get/query/remove/compact/...
|
|
42
|
+
*/
|
|
43
|
+
export function openMemoryShardStore(dir, kind = 'vp', opts = {}) {
|
|
44
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
// Hydrate any pre-existing project-* shards from disk so the schema
|
|
47
|
+
// allow-list recognises them on this open.
|
|
48
|
+
const extraShards = new Set(opts.extraShards || []);
|
|
49
|
+
for (const name of discoverOnDiskShards(dir)) {
|
|
50
|
+
if (!extraShards.has(name)) extraShards.add(name);
|
|
51
|
+
}
|
|
52
|
+
const schema = buildShardSchema(kind, { extraShards: [...extraShards] });
|
|
53
|
+
const inner = openShardStore(dir, schema);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Put an R6 entry. Merges the rich entry schema into `body` (frontmatter)
|
|
57
|
+
* and surfaces the 5 filter-worthy fields through `meta`.
|
|
58
|
+
*/
|
|
59
|
+
function put(entry) {
|
|
60
|
+
validateR6Entry(entry);
|
|
61
|
+
const body = serialiseR6Body(entry);
|
|
62
|
+
const meta = pickMeta(entry);
|
|
63
|
+
const res = inner.put({
|
|
64
|
+
id: entry.id,
|
|
65
|
+
shard: entry.shard,
|
|
66
|
+
body,
|
|
67
|
+
meta,
|
|
68
|
+
});
|
|
69
|
+
return res;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Retrieve an R6 entry (returns the parsed frontmatter + body). */
|
|
73
|
+
function get(id) {
|
|
74
|
+
const raw = inner.get(id);
|
|
75
|
+
if (!raw) return null;
|
|
76
|
+
const parsed = parseR6Body(raw.body);
|
|
77
|
+
return {
|
|
78
|
+
...parsed,
|
|
79
|
+
id: raw.id,
|
|
80
|
+
shard: raw.shard,
|
|
81
|
+
_meta: raw.meta || {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Query. See storage/shard-store.js for the filter shape. */
|
|
86
|
+
function query(filter = {}) {
|
|
87
|
+
const res = inner.query(filter);
|
|
88
|
+
return {
|
|
89
|
+
results: res.results.map(mapRecordToThinEntry),
|
|
90
|
+
needsRecompression: res.needsRecompression.slice(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Remove an entry; underlying shard compacts immediately. */
|
|
95
|
+
function remove(id) { return inner.remove(id); }
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Supersede: create a new entry N that replaces olds M[...].
|
|
99
|
+
* Writes N with supersedes=M[...], then marks each M.supersededBy=N.
|
|
100
|
+
* Old entries are NOT removed — they stay for audit / memory_trace.
|
|
101
|
+
*/
|
|
102
|
+
function supersede({ newEntry, oldIds }) {
|
|
103
|
+
validateR6Entry(newEntry);
|
|
104
|
+
if (!Array.isArray(oldIds) || oldIds.length === 0) {
|
|
105
|
+
throw new Error('supersede: oldIds required (non-empty)');
|
|
106
|
+
}
|
|
107
|
+
const supersedes = oldIds.slice();
|
|
108
|
+
const write = { ...newEntry, supersedes };
|
|
109
|
+
const r = put(write);
|
|
110
|
+
|
|
111
|
+
for (const oldId of oldIds) {
|
|
112
|
+
const existing = get(oldId);
|
|
113
|
+
if (!existing) continue;
|
|
114
|
+
const updated = { ...existing, supersededBy: newEntry.id };
|
|
115
|
+
put(updated);
|
|
116
|
+
}
|
|
117
|
+
return r;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Atomic re-compression handoff:
|
|
122
|
+
* caller writes the new shard body to `memory-<shard>.md.compacting`,
|
|
123
|
+
* then calls `commitRecompression(shard)` which atomically renames it
|
|
124
|
+
* over the live file. Readers always see either the old or the new file.
|
|
125
|
+
*
|
|
126
|
+
* Consumers (334g dream) build the new body themselves; we just manage
|
|
127
|
+
* the rename + stats recomputation.
|
|
128
|
+
*/
|
|
129
|
+
function stageRecompression(shardName, newBody) {
|
|
130
|
+
const tmpPath = join(dir, `memory-${shardName}.md${COMPACTING_SUFFIX}`);
|
|
131
|
+
writeAtomic(tmpPath, newBody);
|
|
132
|
+
return tmpPath;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function commitRecompression(shardName) {
|
|
136
|
+
const livePath = join(dir, `memory-${shardName}.md`);
|
|
137
|
+
const tmpPath = join(dir, `memory-${shardName}.md${COMPACTING_SUFFIX}`);
|
|
138
|
+
if (!existsSync(tmpPath)) {
|
|
139
|
+
throw new Error(`commitRecompression: no tmp file at ${tmpPath}`);
|
|
140
|
+
}
|
|
141
|
+
renameSync(tmpPath, livePath);
|
|
142
|
+
// The caller-supplied body replaced the whole shard. The old index rows
|
|
143
|
+
// for this shard are stale (new ids / offsets). Rebuild that shard's
|
|
144
|
+
// index rows from disk by delegating to the storage primitive — it walks
|
|
145
|
+
// START/END markers and recomputes offsets. We keep other shards intact.
|
|
146
|
+
const schema = buildShardSchema(kind, { extraShards: [...extraShards] });
|
|
147
|
+
const rebuilt = rebuildShardIndexFromDisk(dir, schema);
|
|
148
|
+
const innerIndex = inner.getIndex();
|
|
149
|
+
// Swap this shard's rows + bucket.
|
|
150
|
+
innerIndex.entries = innerIndex.entries.filter(e => e.shard !== shardName)
|
|
151
|
+
.concat(rebuilt.entries.filter(e => e.shard === shardName));
|
|
152
|
+
if (rebuilt.shards[shardName]) {
|
|
153
|
+
innerIndex.shards[shardName] = rebuilt.shards[shardName];
|
|
154
|
+
}
|
|
155
|
+
saveShardIndex(dir, innerIndex);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function abortRecompression(shardName) {
|
|
159
|
+
const tmpPath = join(dir, `memory-${shardName}.md${COMPACTING_SUFFIX}`);
|
|
160
|
+
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Return shard-level stats (byte count, entry count, soft cap). */
|
|
164
|
+
function stats() {
|
|
165
|
+
const raw = inner.stats();
|
|
166
|
+
const shards = {};
|
|
167
|
+
for (const [name, bucket] of Object.entries(raw.shards)) {
|
|
168
|
+
shards[name] = { ...bucket, softCap: softCapFor(name) };
|
|
169
|
+
}
|
|
170
|
+
return { shards, count: raw.count };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Hint for the dream layer — returns `project-<slug>` candidate when
|
|
175
|
+
* ≥ PROJECT_DERIVE_THRESHOLD entries share a groupId and no project
|
|
176
|
+
* shard exists for that slug yet. Returns `null` otherwise.
|
|
177
|
+
*
|
|
178
|
+
* The actual derive (creating the shard file + re-compressing entries
|
|
179
|
+
* into it) is 334g dream work; this slice only advertises the hint so
|
|
180
|
+
* dream can schedule.
|
|
181
|
+
*/
|
|
182
|
+
function projectDeriveHint() {
|
|
183
|
+
const groupCounts = new Map();
|
|
184
|
+
const { results } = inner.query({});
|
|
185
|
+
for (const rec of results) {
|
|
186
|
+
const gid = rec.meta?.groupId;
|
|
187
|
+
if (!gid) continue;
|
|
188
|
+
groupCounts.set(gid, (groupCounts.get(gid) || 0) + 1);
|
|
189
|
+
}
|
|
190
|
+
for (const [gid, count] of groupCounts) {
|
|
191
|
+
if (count < PROJECT_DERIVE_THRESHOLD) continue;
|
|
192
|
+
const slug = slugify(gid);
|
|
193
|
+
const shardName = `project-${slug}`;
|
|
194
|
+
const shardNames = Object.keys(inner.stats().shards);
|
|
195
|
+
if (shardNames.includes(shardName)) continue;
|
|
196
|
+
if (shardNames.length >= MAX_VP_SHARDS) continue;
|
|
197
|
+
return { groupId: gid, shard: shardName, count };
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function close() { /* underlying store is stateless (index saved on every op) */ }
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
put,
|
|
206
|
+
get,
|
|
207
|
+
query,
|
|
208
|
+
remove,
|
|
209
|
+
supersede,
|
|
210
|
+
stageRecompression,
|
|
211
|
+
commitRecompression,
|
|
212
|
+
abortRecompression,
|
|
213
|
+
stats,
|
|
214
|
+
projectDeriveHint,
|
|
215
|
+
close,
|
|
216
|
+
_innerForTest: inner,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Serialisation ──────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function serialiseR6Body(entry) {
|
|
223
|
+
const fm = ['---'];
|
|
224
|
+
fm.push(`id: ${entry.id}`);
|
|
225
|
+
if (entry.vp) fm.push(`vp: ${entry.vp}`);
|
|
226
|
+
if (entry.taskId) fm.push(`taskId: ${entry.taskId}`);
|
|
227
|
+
fm.push(`kind: ${entry.kind}`);
|
|
228
|
+
fm.push(`shard: ${entry.shard}`);
|
|
229
|
+
if (entry.sourceRef) {
|
|
230
|
+
fm.push('sourceRef:');
|
|
231
|
+
if (entry.sourceRef.groupId) fm.push(` groupId: ${entry.sourceRef.groupId}`);
|
|
232
|
+
if (entry.sourceRef.taskId) fm.push(` taskId: ${entry.sourceRef.taskId}`);
|
|
233
|
+
if (Array.isArray(entry.sourceRef.msgIds) && entry.sourceRef.msgIds.length) {
|
|
234
|
+
fm.push(` msgIds: [${entry.sourceRef.msgIds.join(', ')}]`);
|
|
235
|
+
}
|
|
236
|
+
if (entry.sourceRef.timeWindow) fm.push(` timeWindow: ${entry.sourceRef.timeWindow}`);
|
|
237
|
+
if (entry.sourceRef.hint) fm.push(` hint: ${JSON.stringify(entry.sourceRef.hint)}`);
|
|
238
|
+
}
|
|
239
|
+
if (Array.isArray(entry.supersedes) && entry.supersedes.length) {
|
|
240
|
+
fm.push(`supersedes: [${entry.supersedes.join(', ')}]`);
|
|
241
|
+
}
|
|
242
|
+
if (entry.supersededBy) fm.push(`supersededBy: ${entry.supersededBy}`);
|
|
243
|
+
if (entry.pinned != null) fm.push(`pinned: ${entry.pinned ? 'true' : 'false'}`);
|
|
244
|
+
if (Array.isArray(entry.tags) && entry.tags.length) {
|
|
245
|
+
fm.push(`tags: [${entry.tags.join(', ')}]`);
|
|
246
|
+
}
|
|
247
|
+
if (entry.authoredBy) fm.push(`authoredBy: ${entry.authoredBy}`);
|
|
248
|
+
const now = new Date().toISOString();
|
|
249
|
+
fm.push(`createdAt: ${entry.createdAt || now}`);
|
|
250
|
+
fm.push(`updatedAt: ${now}`);
|
|
251
|
+
fm.push('---');
|
|
252
|
+
fm.push('');
|
|
253
|
+
fm.push(entry.body || entry.content || '');
|
|
254
|
+
return fm.join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseR6Body(raw) {
|
|
258
|
+
if (!raw || !raw.startsWith('---')) return { body: raw || '' };
|
|
259
|
+
const endIdx = raw.indexOf('\n---', 3);
|
|
260
|
+
if (endIdx === -1) return { body: raw };
|
|
261
|
+
const fm = raw.slice(4, endIdx).trim();
|
|
262
|
+
const body = raw.slice(endIdx + 4).replace(/^\n+/, '');
|
|
263
|
+
const out = { body };
|
|
264
|
+
let inSourceRef = false;
|
|
265
|
+
const sourceRef = {};
|
|
266
|
+
for (const line of fm.split('\n')) {
|
|
267
|
+
if (/^sourceRef:\s*$/.test(line)) { inSourceRef = true; continue; }
|
|
268
|
+
if (inSourceRef && /^\s+/.test(line)) {
|
|
269
|
+
const m = line.match(/^\s+(\w+):\s*(.*)$/);
|
|
270
|
+
if (!m) continue;
|
|
271
|
+
const [, k, v] = m;
|
|
272
|
+
if (k === 'msgIds') {
|
|
273
|
+
sourceRef.msgIds = v.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).filter(Boolean);
|
|
274
|
+
} else if (k === 'hint') {
|
|
275
|
+
try { sourceRef.hint = JSON.parse(v); } catch { sourceRef.hint = v; }
|
|
276
|
+
} else {
|
|
277
|
+
sourceRef[k] = v;
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
inSourceRef = false;
|
|
282
|
+
const m = line.match(/^(\w+):\s*(.*)$/);
|
|
283
|
+
if (!m) continue;
|
|
284
|
+
const [, k, v] = m;
|
|
285
|
+
switch (k) {
|
|
286
|
+
case 'id': out.id = v; break;
|
|
287
|
+
case 'vp': out.vp = v; break;
|
|
288
|
+
case 'taskId': out.taskId = v; break;
|
|
289
|
+
case 'kind': out.kind = v; break;
|
|
290
|
+
case 'shard': out.shard = v; break;
|
|
291
|
+
case 'supersededBy': out.supersededBy = v; break;
|
|
292
|
+
case 'pinned': out.pinned = v === 'true'; break;
|
|
293
|
+
case 'authoredBy': out.authoredBy = v; break;
|
|
294
|
+
case 'createdAt': out.createdAt = v; break;
|
|
295
|
+
case 'updatedAt': out.updatedAt = v; break;
|
|
296
|
+
case 'supersedes':
|
|
297
|
+
out.supersedes = v.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).filter(Boolean);
|
|
298
|
+
break;
|
|
299
|
+
case 'tags':
|
|
300
|
+
out.tags = v.replace(/^\[|\]$/g, '').split(',').map(s => s.trim()).filter(Boolean);
|
|
301
|
+
break;
|
|
302
|
+
default: break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (Object.keys(sourceRef).length > 0) out.sourceRef = sourceRef;
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function pickMeta(entry) {
|
|
310
|
+
// Meta is only the fields the 334o query() layer filters on.
|
|
311
|
+
const meta = {};
|
|
312
|
+
if (entry.kind) meta.kind = entry.kind;
|
|
313
|
+
if (entry.tags) meta.tags = entry.tags.slice();
|
|
314
|
+
if (entry.pinned) meta.pinned = true;
|
|
315
|
+
if (entry.sourceRef?.groupId) meta.groupId = entry.sourceRef.groupId;
|
|
316
|
+
if (entry.sourceRef?.taskId) meta.taskId = entry.sourceRef.taskId;
|
|
317
|
+
if (entry.supersededBy) meta.supersededBy = entry.supersededBy;
|
|
318
|
+
return meta;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function mapRecordToThinEntry(rec) {
|
|
322
|
+
return {
|
|
323
|
+
id: rec.id,
|
|
324
|
+
shard: rec.shard,
|
|
325
|
+
kind: rec.meta?.kind,
|
|
326
|
+
tags: rec.meta?.tags || [],
|
|
327
|
+
pinned: Boolean(rec.meta?.pinned),
|
|
328
|
+
groupId: rec.meta?.groupId,
|
|
329
|
+
taskId: rec.meta?.taskId,
|
|
330
|
+
supersededBy: rec.meta?.supersededBy || null,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function slugify(s) {
|
|
335
|
+
return String(s || '')
|
|
336
|
+
.toLowerCase()
|
|
337
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
338
|
+
.replace(/^-+|-+$/g, '')
|
|
339
|
+
.slice(0, 40);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function discoverOnDiskShards(dir) {
|
|
343
|
+
try {
|
|
344
|
+
return readdirSync(dir)
|
|
345
|
+
.filter(f => /^memory-[A-Za-z0-9_-]+\.md$/.test(f))
|
|
346
|
+
.map(f => f.replace(/^memory-/, '').replace(/\.md$/, ''));
|
|
347
|
+
} catch { return []; }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── Migration stub (§Δ23 / 334i) ───────────────────────────────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Migration stub: map a legacy R5 `memory-NNN.md` shard file path into a
|
|
354
|
+
* semantic shard assignment. Actual batch migration runs in 334i; this
|
|
355
|
+
* slice only defines the classifier API so dependent code can stub it.
|
|
356
|
+
*
|
|
357
|
+
* @param {object} legacyEntry parsed legacy entry { kind, scope, tags, ... }
|
|
358
|
+
* @returns {string} semantic shard name ("skill" / "lessons" / ...)
|
|
359
|
+
*/
|
|
360
|
+
export function classifyLegacyEntryToShard(legacyEntry) {
|
|
361
|
+
if (!legacyEntry || typeof legacyEntry !== 'object') return 'skill';
|
|
362
|
+
const kind = legacyEntry.kind || 'fact';
|
|
363
|
+
switch (kind) {
|
|
364
|
+
case 'lesson': return 'lessons';
|
|
365
|
+
case 'preference': return 'preferences';
|
|
366
|
+
case 'identity': return 'preferences';
|
|
367
|
+
case 'relation': return 'relations';
|
|
368
|
+
case 'skill': return 'skill';
|
|
369
|
+
default: return 'skill';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export { AUTHORED_BY };
|
package/unify/prompts.js
CHANGED
|
@@ -386,8 +386,17 @@ const DEFAULT_TASK_MEMORY_TOP = 5;
|
|
|
386
386
|
const DEFAULT_RELATED_TASK_TOP = 3;
|
|
387
387
|
const DEFAULT_RELATED_TASK_MEMORY_TOP = 2;
|
|
388
388
|
const DEFAULT_CORE_MEMORY_TOP = 7;
|
|
389
|
+
// task-334n §Δ31.4 — tightened reminder gate:
|
|
390
|
+
// (a) currentVpId === initiatorVpId
|
|
391
|
+
// (b) task.members.length >= 2 (multi-VP only)
|
|
392
|
+
// (c) nonSummaryCount >= 10 OR (now - lastSummaryAt) >= 20 min
|
|
393
|
+
// 334e's earlier looser gate (3 msgs / 15 min) is preserved as a legacy
|
|
394
|
+
// fallback path for callers that never set `summaryReminder.members`.
|
|
389
395
|
const SUMMARY_REMINDER_MIN_MESSAGES = 3;
|
|
390
|
-
const SUMMARY_REMINDER_MIN_AGE_MS = 15 * 60 * 1000; // 15 minutes
|
|
396
|
+
const SUMMARY_REMINDER_MIN_AGE_MS = 15 * 60 * 1000; // 15 minutes (legacy)
|
|
397
|
+
const SUMMARY_REMINDER_MIN_TURNS_334N = 10;
|
|
398
|
+
const SUMMARY_REMINDER_MIN_AGE_MS_334N = 20 * 60 * 1000; // 20 minutes
|
|
399
|
+
const SUMMARY_REMINDER_MIN_MEMBERS_334N = 2;
|
|
391
400
|
|
|
392
401
|
/**
|
|
393
402
|
* Render `## task_ctx` block. Never throws on malformed input — missing
|
|
@@ -402,6 +411,7 @@ function renderTaskCtx(taskCtx, lang) {
|
|
|
402
411
|
taskCtx.relatedTasks,
|
|
403
412
|
taskCtx.currentVpId,
|
|
404
413
|
lang,
|
|
414
|
+
taskCtx.groupId,
|
|
405
415
|
);
|
|
406
416
|
const reminderLine = renderSummaryReminder(taskCtx, lang);
|
|
407
417
|
|
|
@@ -436,13 +446,17 @@ function renderTaskMemories(memories) {
|
|
|
436
446
|
* Ordering: by `updatedAt` desc (undefined treated as 0). Top-3 tasks, top-2
|
|
437
447
|
* memory each.
|
|
438
448
|
*/
|
|
439
|
-
function renderRelatedTasks(relatedTasks, currentVpId, lang) {
|
|
449
|
+
function renderRelatedTasks(relatedTasks, currentVpId, lang, currentTaskGroupId) {
|
|
440
450
|
if (!Array.isArray(relatedTasks) || relatedTasks.length === 0) return '';
|
|
441
451
|
if (!currentVpId) return ''; // no ACL subject → fail-closed
|
|
442
452
|
|
|
443
453
|
const allowed = relatedTasks.filter((t) => {
|
|
444
454
|
if (!t || typeof t !== 'object') return false;
|
|
445
455
|
const members = Array.isArray(t.members) ? t.members : null;
|
|
456
|
+
// task-334n §Δ27.3 — either same-group OR members-intersection grants.
|
|
457
|
+
if (currentTaskGroupId && t.groupId && t.groupId === currentTaskGroupId) {
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
446
460
|
if (!members) return false; // fail-closed on missing ACL
|
|
447
461
|
return members.includes(currentVpId);
|
|
448
462
|
});
|
|
@@ -487,16 +501,26 @@ function renderSummaryReminder(taskCtx, lang) {
|
|
|
487
501
|
if (taskCtx.currentVpId !== taskCtx.initiatorVpId) return '';
|
|
488
502
|
|
|
489
503
|
const count = Number(r.nonSummaryCount) || 0;
|
|
490
|
-
if (count < SUMMARY_REMINDER_MIN_MESSAGES) return '';
|
|
491
|
-
|
|
492
504
|
const now = Number(r.now) || Date.now();
|
|
493
505
|
const lastAt = Number(r.lastSummaryAt) || 0;
|
|
494
506
|
const ageMs = lastAt > 0 ? now - lastAt : Number.POSITIVE_INFINITY;
|
|
495
|
-
if (lastAt > 0 && ageMs <= SUMMARY_REMINDER_MIN_AGE_MS) return '';
|
|
496
507
|
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
508
|
+
// task-334n §Δ31.4 gate: when `members` is supplied, apply the strict
|
|
509
|
+
// multi-VP / 20min-or-10turn rule. Otherwise keep the legacy 334e gate
|
|
510
|
+
// so pre-334n callers still see reminders under the old thresholds.
|
|
511
|
+
const members = Array.isArray(r.members) ? r.members : null;
|
|
512
|
+
if (members) {
|
|
513
|
+
if (members.length < SUMMARY_REMINDER_MIN_MEMBERS_334N) return '';
|
|
514
|
+
const ageOk = lastAt > 0 && ageMs >= SUMMARY_REMINDER_MIN_AGE_MS_334N;
|
|
515
|
+
const turnsOk = count >= SUMMARY_REMINDER_MIN_TURNS_334N;
|
|
516
|
+
// `never summarised` (lastAt=0) only counts when turnsOk, otherwise we
|
|
517
|
+
// silently wait — aligns with §Δ31.4 "too-soon" reason code.
|
|
518
|
+
if (!ageOk && !turnsOk) return '';
|
|
519
|
+
} else {
|
|
520
|
+
if (count < SUMMARY_REMINDER_MIN_MESSAGES) return '';
|
|
521
|
+
if (lastAt > 0 && ageMs <= SUMMARY_REMINDER_MIN_AGE_MS) return '';
|
|
522
|
+
}
|
|
523
|
+
|
|
500
524
|
const minStr = lastAt > 0 ? String(Math.round(ageMs / 60000)) : '—';
|
|
501
525
|
return lang.taskCtxSummaryReminder(minStr, count);
|
|
502
526
|
}
|
package/unify/tasks/store.js
CHANGED
|
@@ -37,6 +37,15 @@ function serializeTask(task) {
|
|
|
37
37
|
if (task.parentTaskId) fm.push(`parentTaskId: ${task.parentTaskId}`);
|
|
38
38
|
if (task.parentId) fm.push(`parentId: ${task.parentId}`);
|
|
39
39
|
if (task.primaryThreadId) fm.push(`primaryThreadId: ${task.primaryThreadId}`);
|
|
40
|
+
// task-334n — multi-VP collaboration protocol fields.
|
|
41
|
+
// initiator: VP id that created the task (fallback target for ACL / reminder).
|
|
42
|
+
// members: explicit VP roster for the task (supersedes group roster when set).
|
|
43
|
+
// groupId: the group this task belongs to (null for legacy / standalone).
|
|
44
|
+
if (task.initiator) fm.push(`initiator: ${task.initiator}`);
|
|
45
|
+
if (Array.isArray(task.members) && task.members.length) {
|
|
46
|
+
fm.push(`members: [${task.members.join(', ')}]`);
|
|
47
|
+
}
|
|
48
|
+
if (task.groupId) fm.push(`groupId: ${task.groupId}`);
|
|
40
49
|
if (task.createdAt) fm.push(`createdAt: ${task.createdAt}`);
|
|
41
50
|
if (task.updatedAt) fm.push(`updatedAt: ${task.updatedAt}`);
|
|
42
51
|
|
|
@@ -81,6 +90,13 @@ function parseTask(raw) {
|
|
|
81
90
|
|
|
82
91
|
if (key === 'createdAt' || key === 'updatedAt') {
|
|
83
92
|
task[key] = parseInt(val, 10) || 0;
|
|
93
|
+
} else if (key === 'members') {
|
|
94
|
+
// task-334n — members: [vp-a, vp-b, ...]
|
|
95
|
+
task.members = val
|
|
96
|
+
.replace(/^\[|\]$/g, '')
|
|
97
|
+
.split(',')
|
|
98
|
+
.map((s) => s.trim())
|
|
99
|
+
.filter(Boolean);
|
|
84
100
|
} else {
|
|
85
101
|
task[key] = val;
|
|
86
102
|
}
|
|
@@ -181,6 +197,8 @@ export class TaskStore {
|
|
|
181
197
|
#tasks;
|
|
182
198
|
/** @type {boolean} */
|
|
183
199
|
#readOnly;
|
|
200
|
+
/** @type {Array<(evt:any)=>void>} */
|
|
201
|
+
#listeners;
|
|
184
202
|
|
|
185
203
|
/**
|
|
186
204
|
* @param {string} yeaftDir — Base ~/.yeaft directory
|
|
@@ -192,6 +210,7 @@ export class TaskStore {
|
|
|
192
210
|
this.#planPath = join(this.#dir, 'plan.md');
|
|
193
211
|
this.#tasks = new Map();
|
|
194
212
|
this.#readOnly = opts.readOnly || false;
|
|
213
|
+
this.#listeners = [];
|
|
195
214
|
|
|
196
215
|
// Ensure base directory exists
|
|
197
216
|
if (!this.#readOnly) {
|
|
@@ -275,6 +294,122 @@ export class TaskStore {
|
|
|
275
294
|
return task;
|
|
276
295
|
}
|
|
277
296
|
|
|
297
|
+
/**
|
|
298
|
+
* task-334n — add a VP member to a task's collaboration roster.
|
|
299
|
+
* Idempotent: adding an existing member is a no-op (no event emitted).
|
|
300
|
+
* Returns `{ task, added: boolean }`.
|
|
301
|
+
*
|
|
302
|
+
* If an `onEvent` callback was passed at construction time, emits a
|
|
303
|
+
* `task_member_added` event synchronously after the write:
|
|
304
|
+
* { type: 'task_member_added', taskId, vpId, members, ts }
|
|
305
|
+
*/
|
|
306
|
+
addMember(id, vpId) {
|
|
307
|
+
const task = this.#tasks.get(id);
|
|
308
|
+
if (!task) return { task: null, added: false };
|
|
309
|
+
if (!vpId || typeof vpId !== 'string') {
|
|
310
|
+
throw new Error('addMember: vpId required (string)');
|
|
311
|
+
}
|
|
312
|
+
const members = Array.isArray(task.members) ? task.members.slice() : [];
|
|
313
|
+
if (members.includes(vpId)) {
|
|
314
|
+
return { task, added: false };
|
|
315
|
+
}
|
|
316
|
+
members.push(vpId);
|
|
317
|
+
this.update(id, { members });
|
|
318
|
+
this.#emit({
|
|
319
|
+
type: 'task_member_added',
|
|
320
|
+
taskId: id,
|
|
321
|
+
vpId,
|
|
322
|
+
members: members.slice(),
|
|
323
|
+
ts: Date.now(),
|
|
324
|
+
});
|
|
325
|
+
return { task: this.#tasks.get(id), added: true };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* task-334n — remove a VP member from a task.
|
|
330
|
+
* Idempotent: removing a non-member is a no-op (no event emitted).
|
|
331
|
+
* Returns `{ task, removed: boolean }`.
|
|
332
|
+
* Emits `task_member_removed` on successful removal.
|
|
333
|
+
*/
|
|
334
|
+
removeMember(id, vpId) {
|
|
335
|
+
const task = this.#tasks.get(id);
|
|
336
|
+
if (!task) return { task: null, removed: false };
|
|
337
|
+
if (!vpId || typeof vpId !== 'string') {
|
|
338
|
+
throw new Error('removeMember: vpId required (string)');
|
|
339
|
+
}
|
|
340
|
+
const members = Array.isArray(task.members) ? task.members.slice() : [];
|
|
341
|
+
const idx = members.indexOf(vpId);
|
|
342
|
+
if (idx === -1) return { task, removed: false };
|
|
343
|
+
members.splice(idx, 1);
|
|
344
|
+
this.update(id, { members });
|
|
345
|
+
this.#emit({
|
|
346
|
+
type: 'task_member_removed',
|
|
347
|
+
taskId: id,
|
|
348
|
+
vpId,
|
|
349
|
+
members: members.slice(),
|
|
350
|
+
ts: Date.now(),
|
|
351
|
+
});
|
|
352
|
+
return { task: this.#tasks.get(id), removed: true };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* task-334n §Δ27.3 ACL — true iff `vpId` may read `otherTaskId`'s
|
|
357
|
+
* memory/summary. Pass grants when:
|
|
358
|
+
* - both tasks share the same non-null groupId, OR
|
|
359
|
+
* - members sets intersect on at least one vpId
|
|
360
|
+
* Fail-closed: missing task, missing groupId match, no intersection → false.
|
|
361
|
+
*
|
|
362
|
+
* @param {string} currentTaskId — task the caller is running in
|
|
363
|
+
* @param {string} otherTaskId — task whose data the caller wants to read
|
|
364
|
+
* @param {string} [vpId] — caller's vp id; if set, must also be
|
|
365
|
+
* a member of currentTaskId (prevents stranger elevating via URL probe)
|
|
366
|
+
* @returns {boolean}
|
|
367
|
+
*/
|
|
368
|
+
canAccessRelated(currentTaskId, otherTaskId, vpId) {
|
|
369
|
+
if (!currentTaskId || !otherTaskId || currentTaskId === otherTaskId) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
const cur = this.#tasks.get(currentTaskId);
|
|
373
|
+
const other = this.#tasks.get(otherTaskId);
|
|
374
|
+
if (!cur || !other) return false;
|
|
375
|
+
|
|
376
|
+
// If caller claims a vpId, they must be a member of the current task or
|
|
377
|
+
// its initiator. Otherwise this is a cross-context read — fail-closed.
|
|
378
|
+
if (vpId) {
|
|
379
|
+
const curMembers = Array.isArray(cur.members) ? cur.members : [];
|
|
380
|
+
const isInsider = curMembers.includes(vpId) || cur.initiator === vpId;
|
|
381
|
+
if (!isInsider) return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Same-group rule.
|
|
385
|
+
if (cur.groupId && other.groupId && cur.groupId === other.groupId) {
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Members-intersection rule.
|
|
390
|
+
const a = Array.isArray(cur.members) ? cur.members : [];
|
|
391
|
+
const b = Array.isArray(other.members) ? other.members : [];
|
|
392
|
+
if (a.length === 0 || b.length === 0) return false;
|
|
393
|
+
const bSet = new Set(b);
|
|
394
|
+
for (const v of a) if (bSet.has(v)) return true;
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Register an event listener (task-334n member events). */
|
|
399
|
+
onEvent(fn) {
|
|
400
|
+
if (typeof fn === 'function') this.#listeners.push(fn);
|
|
401
|
+
return () => {
|
|
402
|
+
const i = this.#listeners.indexOf(fn);
|
|
403
|
+
if (i >= 0) this.#listeners.splice(i, 1);
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#emit(evt) {
|
|
408
|
+
for (const fn of this.#listeners) {
|
|
409
|
+
try { fn(evt); } catch { /* listener failures must not corrupt store */ }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
278
413
|
/**
|
|
279
414
|
* Get a task by ID.
|
|
280
415
|
* @param {string} id
|