@openviking/dsh-memory-plugin 0.5.30 → 0.5.31

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openviking/dsh-memory-plugin",
3
- "version": "0.5.30",
3
+ "version": "0.5.31",
4
4
  "description": "OpenViking memory and context bundle for DeepSeek Harness",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -8,6 +8,10 @@
8
8
  * viking://user/<space>/memories/entities/ (ls with abstracts)
9
9
  * GET /api/v1/skills (own + account-shared skills)
10
10
  *
11
+ * With an actorPeerId the same three paths under
12
+ * viking://user/<space>/peers/<peer>/memories/ are read too, and each section
13
+ * lists the user root first, then the peer root, under one shared budget.
14
+ *
11
15
  * Budget enforced via the CJK-aware estimateTokens() below — codepoint >=
12
16
  * 0x3000 counts at 1.5 tokens, else chars/4. The estimator is exported so
13
17
  * callers (e.g. session-start.mjs) can log token counts that match what
@@ -240,6 +244,57 @@ function formatListing(headerUri, entries, budgetTokens, moreHint = MEMORY_MORE_
240
244
  return { lines, used, dropped: 0 };
241
245
  }
242
246
 
247
+ /**
248
+ * Preferences and entities of each root in order, user root first. A root
249
+ * with any entry takes its fair share of what is left, and within a root the
250
+ * preferences get half and the entities the rest. With one root this is the
251
+ * single-scope split unchanged.
252
+ */
253
+ function formatListingGroups(scopes, budgetTokens) {
254
+ const populated = scopes.filter((s) => s.prefs.length > 0 || s.ents.length > 0);
255
+ const lines = [];
256
+ let used = 0;
257
+ let droppedPref = 0;
258
+ let droppedEnt = 0;
259
+ for (let i = 0; i < populated.length; i++) {
260
+ const s = populated[i];
261
+ const share = Math.floor(Math.max(0, budgetTokens - used) / (populated.length - i));
262
+ const prefBlock = formatListing(s.prefUri, s.prefs, Math.floor(share / 2));
263
+ const entBlock = formatListing(s.entUri, s.ents, Math.max(0, share - prefBlock.used));
264
+ lines.push(...prefBlock.lines, ...entBlock.lines);
265
+ used += prefBlock.used + entBlock.used;
266
+ droppedPref += prefBlock.dropped;
267
+ droppedEnt += entBlock.dropped;
268
+ }
269
+ return { lines, used, droppedPref, droppedEnt };
270
+ }
271
+
272
+ // Below this a trimmed peer profile is mostly elision markers; point at it instead.
273
+ const PEER_PROFILE_MIN_TOKENS = 200;
274
+ const PEER_PROFILE_POINTER = "Omitted for length; read this uri with the read tool.";
275
+
276
+ /**
277
+ * Drop peer-profile lines that already appear in the user profile, so the
278
+ * shared budget is not spent twice on the same fact. Returns null when no
279
+ * non-empty line is left.
280
+ */
281
+ function dedupeProfileLines(peerProfile, userProfile) {
282
+ const seen = new Set(
283
+ String(userProfile || "").split("\n").map((l) => l.trim()).filter(Boolean),
284
+ );
285
+ const kept = [];
286
+ for (const line of peerProfile.split("\n")) {
287
+ const trimmed = line.trim();
288
+ if (trimmed && seen.has(trimmed)) continue;
289
+ // Removed lines must not leave runs of blank lines behind.
290
+ if (!trimmed && (kept.length === 0 || !kept[kept.length - 1].trim())) continue;
291
+ kept.push(line);
292
+ }
293
+ // Headings alone carry nothing worth a second <user-profile> element.
294
+ const hasContent = kept.some((l) => l.trim() && !/^#{1,6}\s/.test(l.trim()));
295
+ return hasContent ? kept.join("\n").trim() : null;
296
+ }
297
+
243
298
  const AGENT_SKILLS_ROOT = "viking://agent/skills";
244
299
  // The server caps each skill root at node_limit, so the two roots together
245
300
  // can return twice this many; the token budget below decides what fits.
@@ -349,24 +404,26 @@ function formatSkillCatalog(groups, budgetTokens) {
349
404
  /**
350
405
  * Build the profile injection block.
351
406
  *
352
- * Returns null when neither profile.md, either listing, nor the skill catalog
353
- * has any content. The returned `block` is just the inner
407
+ * Returns null when no profile, no listing, and no skill catalog has any
408
+ * content. The returned `block` is just the inner
354
409
  * <user-profile>/<available-memories>/<available-skills> payload — the caller
355
410
  * wraps it in <openviking-context source="...">.
356
411
  *
357
412
  * @param {Function} fetchJSON ov-session.mjs:makeFetchJSON closure
358
- * @param {number} totalBudgetTokens budget for the profile and memory listings
359
- * @param {string} [actorPeerId]
413
+ * @param {number} totalBudgetTokens budget for the profiles and memory listings
414
+ * @param {string} [actorPeerId] adds the peer root alongside the user root
360
415
  * @param {{ skillCatalog?: boolean, skillCatalogTokenBudget?: number, sessionStartMaxBytes?: number }} [options]
361
416
  * callers pass their resolved plugin config: its skillCatalog knob adds
362
417
  * <available-skills>, budgeted separately by skillCatalogTokenBudget.
363
418
  * Without it the block is unchanged.
364
419
  * @returns {Promise<null | {
365
420
  * block: string, chars: number, tokens: number, profileUri: string,
366
- * profileChars: number, prefCount: number, entCount: number,
421
+ * profileChars: number, peerProfileUri: string, peerProfileChars: number,
422
+ * prefCount: number, entCount: number,
367
423
  * droppedPref: number, droppedEnt: number,
368
424
  * skillCount: number, droppedSkill: number, skillTokens: number,
369
425
  * }>}
426
+ * Counts are summed across both roots; profileChars covers both raw profiles.
370
427
  */
371
428
  export async function buildProfileBlock(fetchJSON, totalBudgetTokens, actorPeerId = "", options = {}) {
372
429
  const { skillCatalog = false, skillCatalogTokenBudget = 0, sessionStartMaxBytes = 0 } = options;
@@ -378,42 +435,79 @@ export async function buildProfileBlock(fetchJSON, totalBudgetTokens, actorPeerI
378
435
  : Infinity;
379
436
  const skillBudget = Math.min(skillCatalogTokenBudget, Math.floor(capTokens / 4));
380
437
  const space = await resolveUserSpace(fetchJSON, actorPeerId);
381
- const profileUri = `viking://user/${space}/memories/profile.md`;
382
- const prefUri = `viking://user/${space}/memories/preferences`;
383
- const entUri = `viking://user/${space}/memories/entities`;
384
-
385
- const [profile, prefs, ents, skillGroups] = await Promise.all([
386
- readProfile(fetchJSON, profileUri, actorPeerId),
387
- lsDir(fetchJSON, prefUri, actorPeerId),
388
- lsDir(fetchJSON, entUri, actorPeerId),
438
+
439
+ // Profile, preferences and entities carry no message range, so extraction
440
+ // writes the user's own facts to the user root even inside a peer session:
441
+ // with an actor peer both roots hold memories, and both are read.
442
+ const roots = [`viking://user/${space}/memories`];
443
+ if (actorPeerId) roots.push(`viking://user/${space}/peers/${actorPeerId}/memories`);
444
+ const scopes = roots.map((root) => ({
445
+ profileUri: `${root}/profile.md`,
446
+ prefUri: `${root}/preferences`,
447
+ entUri: `${root}/entities`,
448
+ }));
449
+
450
+ // Every read is independent, and a hook on the SessionStart path pays for
451
+ // every round trip.
452
+ const reads = await Promise.all([
453
+ ...scopes.flatMap((s) => [
454
+ readProfile(fetchJSON, s.profileUri, actorPeerId),
455
+ lsDir(fetchJSON, s.prefUri, actorPeerId),
456
+ lsDir(fetchJSON, s.entUri, actorPeerId),
457
+ ]),
389
458
  skillCatalog && skillBudget > 0
390
459
  ? fetchSkillCatalog(fetchJSON, actorPeerId)
391
460
  : [],
392
461
  ]);
462
+ const skillGroups = reads.pop();
463
+ scopes.forEach((s, i) => {
464
+ [s.profile, s.prefs, s.ents] = reads.slice(i * 3, i * 3 + 3);
465
+ });
466
+ const [user, peer] = scopes;
393
467
  const skills = formatSkillCatalog(skillGroups, skillBudget);
394
468
 
395
- if (!profile && prefs.length === 0 && ents.length === 0 && skills.lines.length === 0) {
469
+ const peerProfile = peer?.profile ? dedupeProfileLines(peer.profile, user.profile) : null;
470
+ if (
471
+ !user.profile && !peerProfile
472
+ && scopes.every((s) => s.prefs.length === 0 && s.ents.length === 0)
473
+ && skills.lines.length === 0
474
+ ) {
396
475
  return null;
397
476
  }
398
477
 
399
478
  const memoryBudget = Math.min(totalBudgetTokens, Math.max(0, capTokens - skills.used));
400
- // Profile gets up to half the total budget; listings split the rest.
479
+ // The profiles get up to half the total budget; listings split the rest.
401
480
  // Sub-cap protects against a runaway profile blowing the listing budget.
481
+ // The user profile comes first; the peer profile only gets what it leaves.
402
482
  const profileBudget = Math.floor(memoryBudget / 2);
403
- const profileTrunc = profile ? elideProfile(profile, profileBudget) : null;
483
+ const profileTrunc = user.profile ? elideProfile(user.profile, profileBudget) : null;
404
484
  const profileTokens = estimateTokens(profileTrunc || "");
405
-
406
- const listingBudget = Math.max(0, memoryBudget - profileTokens);
407
- const halfListing = Math.floor(listingBudget / 2);
408
- const prefBlock = formatListing(prefUri, prefs, halfListing);
409
- const entBudget = Math.max(0, listingBudget - prefBlock.used);
410
- const entBlock = formatListing(entUri, ents, entBudget);
411
-
412
485
  const profileLines = profileTrunc
413
- ? [`<user-profile uri="${profileUri}">`, profileTrunc, `</user-profile>`]
486
+ ? [`<user-profile uri="${user.profileUri}">`, profileTrunc, `</user-profile>`]
414
487
  : [];
415
- let memoryLines = prefBlock.lines.length > 0 || entBlock.lines.length > 0
416
- ? [`<available-memories>`, ...prefBlock.lines, ...entBlock.lines, `</available-memories>`]
488
+ let peerProfileTokens = 0;
489
+ if (peerProfile) {
490
+ const peerBudget = profileBudget - profileTokens;
491
+ // elideProfile keeps at least 400 chars (~600 tokens of CJK), so a trim
492
+ // that still overruns the peer's share falls back to the pointer.
493
+ const peerTrunc = estimateTokens(peerProfile) <= peerBudget || peerBudget >= PEER_PROFILE_MIN_TOKENS
494
+ ? elideProfile(peerProfile, peerBudget)
495
+ : null;
496
+ if (peerTrunc !== null && estimateTokens(peerTrunc) <= peerBudget) {
497
+ profileLines.push(`<user-profile uri="${peer.profileUri}">`, peerTrunc, `</user-profile>`);
498
+ peerProfileTokens = estimateTokens(peerTrunc);
499
+ } else {
500
+ const pointer = `<user-profile uri="${peer.profileUri}">${PEER_PROFILE_POINTER}</user-profile>`;
501
+ profileLines.push(pointer);
502
+ peerProfileTokens = estimateTokens(pointer);
503
+ }
504
+ }
505
+
506
+ const listingBudget = Math.max(0, memoryBudget - profileTokens - peerProfileTokens);
507
+ const listings = formatListingGroups(scopes, listingBudget);
508
+
509
+ let memoryLines = listings.lines.length > 0
510
+ ? [`<available-memories>`, ...listings.lines, `</available-memories>`]
417
511
  : [];
418
512
  let skillLines = skills.lines;
419
513
  const render = () => [...profileLines, ...memoryLines, ...skillLines].join("\n");
@@ -430,16 +524,20 @@ export async function buildProfileBlock(fetchJSON, totalBudgetTokens, actorPeerI
430
524
  }
431
525
  if (!block) return null;
432
526
 
527
+ const prefCount = scopes.reduce((n, s) => n + s.prefs.length, 0);
528
+ const entCount = scopes.reduce((n, s) => n + s.ents.length, 0);
433
529
  return {
434
530
  block,
435
531
  chars: block.length,
436
532
  tokens: estimateTokens(block),
437
- profileUri,
438
- profileChars: profile?.length ?? 0,
439
- prefCount: prefs.length,
440
- entCount: ents.length,
441
- droppedPref: memoryLines.length ? prefBlock.dropped : prefs.length,
442
- droppedEnt: memoryLines.length ? entBlock.dropped : ents.length,
533
+ profileUri: user.profileUri,
534
+ profileChars: (user.profile?.length ?? 0) + (peer?.profile?.length ?? 0),
535
+ peerProfileUri: peer?.profileUri ?? "",
536
+ peerProfileChars: peer?.profile?.length ?? 0,
537
+ prefCount,
538
+ entCount,
539
+ droppedPref: memoryLines.length ? listings.droppedPref : prefCount,
540
+ droppedEnt: memoryLines.length ? listings.droppedEnt : entCount,
443
541
  skillCount: skills.count,
444
542
  droppedSkill: skillLines.length ? skills.dropped : skills.count,
445
543
  skillTokens: skillLines.length ? skills.used : 0,