@wowyuarm/dsh-agent-team 0.1.10 → 0.1.12

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 (135) hide show
  1. package/README.md +17 -4
  2. package/README.zh.md +17 -4
  3. package/package.json +53 -47
  4. package/packages/agent-team/README.md +6 -4
  5. package/packages/agent-team/README.zh.md +6 -4
  6. package/packages/agent-team/lib/context-management.js +40 -50
  7. package/packages/agent-team/lib/context-projection.js +15 -1
  8. package/packages/agent-team/lib/index.js +244 -154
  9. package/packages/agent-team/lib/invariant.js +38 -4
  10. package/packages/agent-team/lib/ledger.js +775 -383
  11. package/packages/agent-team/lib/member-context.js +6 -2
  12. package/packages/agent-team/lib/member-runtime.js +47 -9
  13. package/packages/agent-team/lib/member-time-context.js +20 -5
  14. package/packages/agent-team/lib/mentions.js +56 -0
  15. package/packages/agent-team/lib/pressure-policy.js +33 -9
  16. package/packages/agent-team/lib/session-event-cursor.js +116 -0
  17. package/packages/agent-team/lib/session-remediation.js +38 -26
  18. package/packages/agent-team/lib/spec.js +47 -34
  19. package/packages/agent-team/lib/stored-session-reader.js +138 -0
  20. package/packages/agent-team/lib/typert.host.js +157 -41
  21. package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
  22. package/packages/agent-team/lib/typert.remote-client.js +129 -32
  23. package/packages/agent-team/lib/types/context-management.d.ts +7 -0
  24. package/packages/agent-team/lib/types/context-management.d.ts.map +1 -1
  25. package/packages/agent-team/lib/types/context-projection.d.ts +8 -0
  26. package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -1
  27. package/packages/agent-team/lib/types/index.d.ts +77 -7
  28. package/packages/agent-team/lib/types/index.d.ts.map +1 -1
  29. package/packages/agent-team/lib/types/invariant.d.ts.map +1 -1
  30. package/packages/agent-team/lib/types/ledger.d.ts +251 -20
  31. package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
  32. package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
  33. package/packages/agent-team/lib/types/member-runtime.d.ts +2 -1
  34. package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
  35. package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -1
  36. package/packages/agent-team/lib/types/mentions.d.ts +34 -0
  37. package/packages/agent-team/lib/types/mentions.d.ts.map +1 -0
  38. package/packages/agent-team/lib/types/pressure-policy.d.ts +18 -0
  39. package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -1
  40. package/packages/agent-team/lib/types/session-event-cursor.d.ts +119 -0
  41. package/packages/agent-team/lib/types/session-event-cursor.d.ts.map +1 -0
  42. package/packages/agent-team/lib/types/session-remediation.d.ts +23 -0
  43. package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -1
  44. package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
  45. package/packages/agent-team/lib/types/stored-session-reader.d.ts +92 -0
  46. package/packages/agent-team/lib/types/stored-session-reader.d.ts.map +1 -0
  47. package/packages/agent-team/lib/types/types/entities.d.ts +28 -20
  48. package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
  49. package/packages/agent-team/lib/types/types/operations.d.ts +45 -19
  50. package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
  51. package/packages/agent-team/lib/types/types/requests-results.d.ts +87 -4
  52. package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
  53. package/packages/agent-team/preset/team-member/agent.cordis.yml +49 -7
  54. package/packages/client-agent-team/README.md +1 -1
  55. package/packages/client-agent-team/README.zh.md +1 -1
  56. package/packages/client-agent-team/lib/client.js +2357 -1207
  57. package/packages/client-agent-team/lib/client.js.map +1 -1
  58. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
  59. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +27 -7
  60. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts +18 -0
  61. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts.map +1 -0
  62. package/packages/client-agent-team/lib/types/client/TeamAvatarStack.js +22 -0
  63. package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts +3 -1
  64. package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
  65. package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +167 -35
  66. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
  67. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +23 -27
  68. package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts +11 -5
  69. package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -1
  70. package/packages/client-agent-team/lib/types/client/TeamComposer.js +26 -26
  71. package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts +1 -1
  72. package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts.map +1 -1
  73. package/packages/client-agent-team/lib/types/client/TeamConversation.js +9 -2
  74. package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts +20 -0
  75. package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts.map +1 -0
  76. package/packages/client-agent-team/lib/types/client/TeamCountBadge.js +25 -0
  77. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts +25 -0
  78. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts.map +1 -0
  79. package/packages/client-agent-team/lib/types/client/TeamInboxPage.js +139 -0
  80. package/packages/client-agent-team/lib/types/client/TeamMemberEditor.d.ts.map +1 -1
  81. package/packages/client-agent-team/lib/types/client/TeamMemberEditor.js +8 -24
  82. package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts +43 -0
  83. package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts.map +1 -0
  84. package/packages/client-agent-team/lib/types/client/TeamMemberRow.js +27 -0
  85. package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts +2 -2
  86. package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts.map +1 -1
  87. package/packages/client-agent-team/lib/types/client/TeamMembersAction.js +2 -2
  88. package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts +9 -2
  89. package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts.map +1 -1
  90. package/packages/client-agent-team/lib/types/client/TeamMessage.js +11 -4
  91. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts +8 -0
  92. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts.map +1 -1
  93. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.js +21 -1
  94. package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
  95. package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +108 -30
  96. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
  97. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
  98. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +68 -7
  99. package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
  100. package/packages/client-agent-team/lib/types/client/index.js +19 -9
  101. package/packages/client-agent-team/lib/types/client/locales.d.ts +40 -2
  102. package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
  103. package/packages/client-agent-team/lib/types/client/locales.js +40 -2
  104. package/packages/client-agent-team/lib/types/client/navigation.d.ts +10 -0
  105. package/packages/client-agent-team/lib/types/client/navigation.d.ts.map +1 -1
  106. package/packages/client-agent-team/lib/types/client/navigation.js +18 -7
  107. package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts +41 -0
  108. package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts.map +1 -0
  109. package/packages/client-agent-team/lib/types/client/scope-coverage.js +64 -0
  110. package/packages/client-agent-team/lib/types/client/slots.d.ts +7 -2
  111. package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
  112. package/packages/client-agent-team/lib/types/client/team-changes.d.ts +33 -2
  113. package/packages/client-agent-team/lib/types/client/team-changes.d.ts.map +1 -1
  114. package/packages/client-agent-team/lib/types/client/team-changes.js +107 -25
  115. package/packages/client-agent-team/lib/types/client/team-dialog-save.d.ts +20 -0
  116. package/packages/client-agent-team/lib/types/client/team-dialog-save.d.ts.map +1 -0
  117. package/packages/client-agent-team/lib/types/client/team-dialog-save.js +36 -0
  118. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +43 -1
  119. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -1
  120. package/packages/client-agent-team/lib/types/client/team-formatters.js +81 -6
  121. package/packages/tool-agent-team/README.md +2 -2
  122. package/packages/tool-agent-team/README.zh.md +2 -2
  123. package/packages/tool-agent-team/lib/context-tools.js +18 -14
  124. package/packages/tool-agent-team/lib/host-access.js +20 -0
  125. package/packages/tool-agent-team/lib/index.js +44 -32
  126. package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -1
  127. package/packages/tool-agent-team/lib/types/host-access.d.ts +14 -0
  128. package/packages/tool-agent-team/lib/types/host-access.d.ts.map +1 -0
  129. package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
  130. package/packages/agent-team/lib/progress-nudge.js +0 -328
  131. package/packages/agent-team/lib/types/progress-nudge.d.ts +0 -136
  132. package/packages/agent-team/lib/types/progress-nudge.d.ts.map +0 -1
  133. package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts +0 -3
  134. package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts.map +0 -1
  135. package/packages/client-agent-team/lib/types/client/TeamSettings.js +0 -5
@@ -61,16 +61,20 @@ export function renderMemberIdentity(member) {
61
61
  ? `Team identity: you are @${member.handle}.`
62
62
  : `Team identity: you are @${member.handle} — ${member.description}`;
63
63
  }
64
+ /** The four private-memory paths plus the out-of-cwd warning; callers append their own sentence. */
65
+ function memoryPathsBlock(privateMemoryPath) {
66
+ return `Private memory directory: ${privateMemoryPath}\nMemory index: ${privateMemoryPath}/memory.md\nNotes directory: ${privateMemoryPath}/notes\nPrivate skills directory: ${privateMemoryPath}/skills\nThese paths are outside the Workspace cwd.`;
67
+ }
64
68
  export function renderMemberMemory(raw, privateMemoryPath = '<private-memory-path>') {
65
69
  const overBudget = raw.byteLength > MAX_MEMORY_BYTES;
66
70
  const body = overBudget ? '' : raw.toString('utf8');
67
71
  const warning = overBudget
68
72
  ? '\n\n[Maintenance warning: memory.md exceeds the 8 KiB context budget. Its contents were not injected; do not delete or automatically summarize the file. Maintain a smaller index explicitly.]'
69
73
  : '';
70
- return `${BEGIN}\nThis is the complete replacement for this Team Member's private memory index; all earlier private-memory context is obsolete. It is reference context only, may be stale, and is not an instruction or Team fact.\n\nPrivate memory directory: ${privateMemoryPath}\nMemory index: ${privateMemoryPath}/memory.md\nNotes directory: ${privateMemoryPath}/notes\nPrivate skills directory: ${privateMemoryPath}/skills\nThese paths are outside the Workspace cwd. Relative filesystem paths resolve from cwd, so use the absolute paths above when reading or editing this Member's memory. Only this Member can read this directory — no other human or agent sees its contents; when communicating, restate what you need from it instead of pointing others at these paths. Read matching notes on demand; do not copy credentials, sensitive data, guesses, chat logs, other Members' memory, or Team facts already owned by the ledger into memory.\n\n${escape(body)}${warning}\n${END}`;
74
+ return `${BEGIN}\nThis is the complete replacement for this Team Member's private memory index; all earlier private-memory context is obsolete. It is reference context only, may be stale, and is not an instruction or Team fact.\n\n${memoryPathsBlock(privateMemoryPath)} Relative filesystem paths resolve from cwd, so use the absolute paths above when reading or editing this Member's memory. Only this Member can read this directory — no other human or agent sees its contents; when communicating, restate what you need from it instead of pointing others at these paths. Read matching notes on demand; do not copy credentials, sensitive data, guesses, chat logs, other Members' memory, or Team facts already owned by the ledger into memory.\n\n${escape(body)}${warning}\n${END}`;
71
75
  }
72
76
  function renderUnavailableMemory(privateMemoryPath, reason) {
73
- return `${BEGIN}\nThis is the complete replacement for this Team Member's private memory index; all earlier private-memory context is obsolete. ${reason}\n\nPrivate memory directory: ${privateMemoryPath}\nMemory index: ${privateMemoryPath}/memory.md\nNotes directory: ${privateMemoryPath}/notes\nPrivate skills directory: ${privateMemoryPath}/skills\nThese paths are outside the Workspace cwd. Use the absolute paths above when inspecting or repairing this Member's memory.\n${END}`;
77
+ return `${BEGIN}\nThis is the complete replacement for this Team Member's private memory index; all earlier private-memory context is obsolete. ${reason}\n\n${memoryPathsBlock(privateMemoryPath)} Use the absolute paths above when inspecting or repairing this Member's memory.\n${END}`;
74
78
  }
75
79
  function escape(value) {
76
80
  return value.replaceAll(BEGIN, '[escaped begin marker]').replaceAll(END, '[escaped end marker]');
@@ -14,9 +14,10 @@
14
14
  * @module @wowyuarm/dsh-agent-team/member-runtime
15
15
  */
16
16
  import { mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
17
- import { dirname, join, resolve } from 'node:path';
17
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { scopeOf } from '@deepseek-ai/dsh-scope';
20
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
20
21
  import * as memberSkills from "./member-skills.js";
21
22
  /**
22
23
  * Read-only core skills shipped beside the preset (the meta skill first:
@@ -218,7 +219,8 @@ export class MemberRuntime {
218
219
  * `privateMemoryPath` is a durable Member fact the renewal path cannot
219
220
  * rewrite: when the legacy directory exists it is renamed onto the sanitized
220
221
  * path once (same-parent rename, atomic), so existing private memory
221
- * survives instead of being silently orphaned.
222
+ * survives instead of being silently orphaned. A recorded path that names a
223
+ * different DSH home is never acted on — see `migrateLegacyMemoryDirectory`.
222
224
  *
223
225
  * A colon-form twin directory is also merged when it exists: a Member may
224
226
  * have written files under the ledger identity spelling (the branded
@@ -231,8 +233,14 @@ export class MemberRuntime {
231
233
  * and twin-only notes/skills are moved in without overwriting.
232
234
  */
233
235
  async initializePrivateMemory(path, legacyPath) {
234
- if (legacyPath !== undefined && legacyPath !== path)
235
- await migrateLegacyMemoryDirectory(legacyPath, path);
236
+ if (legacyPath !== undefined && legacyPath !== path) {
237
+ const migration = await migrateLegacyMemoryDirectory(legacyPath, path);
238
+ // A refusal means the record names a directory in ANOTHER DSH home: this
239
+ // process provisions the Member fresh here instead of taking that
240
+ // directory out of its own home.
241
+ if (migration === 'refused-foreign-home')
242
+ this.deps.ctx.logger.warn(`agent-team: refused to migrate the recorded Member private memory '${legacyPath}' into '${path}': they are not in the same directory, so the recorded directory names a different DSH home and is left untouched`);
243
+ }
236
244
  const twinPath = twinMemoryDirectoryPath(path);
237
245
  if (twinPath !== undefined)
238
246
  await mergeTwinMemoryDirectory(twinPath, path);
@@ -250,12 +258,20 @@ export class MemberRuntime {
250
258
  async cleanupRemovedMember(member) {
251
259
  // The ledger path may still name the legacy colon directory (never
252
260
  // activated after the fix): remove both spellings; rm is force-tolerant
253
- // of the one that does not exist.
261
+ // of the one that does not exist. Every target must sit inside THIS
262
+ // process's members root: the recorded path is a durable absolute fact,
263
+ // and a process resolving another DSH home (an isolated test home, a
264
+ // second instance) would otherwise delete the directory that fact names —
265
+ // the real Member's private memory.
254
266
  const sanitized = memberMemoryDirectoryPath(member);
267
+ const targets = sanitized === member.privateMemoryPath ? [sanitized] : [sanitized, member.privateMemoryPath];
268
+ const owned = targets.filter(target => isInsideMembersRoot(target));
269
+ const refused = targets.filter(target => !isInsideMembersRoot(target));
270
+ if (refused.length > 0)
271
+ this.deps.ctx.logger.warn(`agent-team: refused to delete recorded Member private memory outside this DSH home: ${refused.map(target => `'${target}'`).join(', ')}`);
255
272
  const results = await Promise.allSettled([
256
273
  this.deps.ctx.workspaceRegistry.archiveSession(member.sessionId),
257
- rm(sanitized, { recursive: true, force: true }),
258
- ...(sanitized === member.privateMemoryPath ? [] : [rm(member.privateMemoryPath, { recursive: true, force: true })]),
274
+ ...owned.map(target => rm(target, { recursive: true, force: true })),
259
275
  ]);
260
276
  const failures = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
261
277
  if (failures.length > 0)
@@ -288,6 +304,14 @@ export class MemberRuntime {
288
304
  * directory onto its sanitized path. A sanitized target that already exists
289
305
  * wins (idempotent across restarts and partially migrated installs); a legacy
290
306
  * source that never existed is simply the fresh-install case.
307
+ *
308
+ * The rename is confined to one parent directory, which is what the migration
309
+ * has always meant: the recorded path is a durable absolute fact, so a process
310
+ * resolving a different DSH home (an isolated test home, a second instance, a
311
+ * moved home) holds a sanitized target in ITS tree while the record still names
312
+ * the real one. Renaming across trees would take a Member's whole private
313
+ * memory out of its home — silently, since only the next session notices an
314
+ * empty index. Such a source is left exactly where it is.
291
315
  */
292
316
  async function migrateLegacyMemoryDirectory(legacyPath, path) {
293
317
  let legacy;
@@ -296,20 +320,34 @@ async function migrateLegacyMemoryDirectory(legacyPath, path) {
296
320
  }
297
321
  catch (error) {
298
322
  if (error.code === 'ENOENT')
299
- return;
323
+ return 'absent';
300
324
  throw error;
301
325
  }
302
326
  if (!legacy.isDirectory())
303
327
  throw new Error(`legacy Member memory path '${legacyPath}' exists but is not a directory`);
328
+ if (dirname(legacyPath) !== dirname(path))
329
+ return 'refused-foreign-home';
304
330
  try {
305
331
  await stat(path);
306
- return; // Sanitized directory already present: migration already done or a new install.
332
+ return 'target-present'; // Sanitized directory already present: migration already done or a new install.
307
333
  }
308
334
  catch (error) {
309
335
  if (error.code !== 'ENOENT')
310
336
  throw error;
311
337
  }
312
338
  await rename(legacyPath, path);
339
+ return 'migrated';
340
+ }
341
+ /**
342
+ * Whether one absolute path names a Member directory inside THIS process's
343
+ * members root. Only such a path is this process's to delete: a recorded path
344
+ * may name the same Member in another DSH home, where the removal happening
345
+ * here has no authority.
346
+ */
347
+ function isInsideMembersRoot(candidate) {
348
+ const fromRoot = relative(dshHomePath('agent-team', 'members'), resolve(candidate));
349
+ // '' is the members root itself and '..' escapes it: only a real descendant is authorized.
350
+ return fromRoot.length > 0 && !fromRoot.startsWith('..') && !isAbsolute(fromRoot);
313
351
  }
314
352
  /** The colon-form twin path of one sanitized Member memory directory, when the segment form admits one. */
315
353
  function twinMemoryDirectoryPath(sanitizedPath) {
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
30
30
  import { formatTeamDuration, formatTeamTimestamp } from "./time-format.js";
31
+ import { advanceOwnedSessionEventCursor } from "./session-event-cursor.js";
31
32
  export const name = 'wowyuarm-agent-team-member-time-context';
32
33
  /** Default minimum spacing between two snapshots within one turn, in ms. */
33
34
  export const CLOCK_REFRESH_INTERVAL_MS = 1_800_000;
@@ -90,8 +91,22 @@ export function renderClockSnapshot(input) {
90
91
  + `Elapsed since the preceding ${baseline}: ${elapsed}.\n`
91
92
  + 'Team collaboration timestamps use UTC+8. Sequence and revision, not wall-clock time, determine ordering and concurrency.';
92
93
  }
94
+ /**
95
+ * The clock baseline folded incrementally over one Session's own events. A
96
+ * rollover starts a fresh Session log, and the cache below is keyed by Session
97
+ * id, so the fold never guesses elapsed across generations: a missing prior
98
+ * event renders `unavailable`, not a fabricated baseline.
99
+ */
100
+ const CLOCK_FOLD = {
101
+ start: emptyBaseline(),
102
+ step: (state, event) => applyClockEvent(state, event),
103
+ };
93
104
  export function apply(ctx, config = {}) {
94
105
  const refreshIntervalMs = config.refreshIntervalMs ?? CLOCK_REFRESH_INTERVAL_MS;
106
+ // Per-step fold state, reused for this plugin lifecycle: one entry per
107
+ // Member, replaced when that Member's Session changes, so a rollover neither
108
+ // resumes across generations nor retains every generation it leaves behind.
109
+ const cursors = new Map();
95
110
  ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
96
111
  const decision = await next();
97
112
  if (decision.kind === 'reject' || signal.aborted)
@@ -102,13 +117,13 @@ export function apply(ctx, config = {}) {
102
117
  const host = ctx.get('agentTeam');
103
118
  if (host === undefined)
104
119
  return decision;
105
- if (host.memberForAgent(agent) === undefined)
120
+ const member = host.memberForAgent(agent);
121
+ if (member === undefined)
106
122
  return decision;
107
123
  const now = Date.now();
108
- // A rollover starts a fresh Session log, so the fold never guesses
109
- // elapsed across generations: a missing prior event renders
110
- // `unavailable`, not a fabricated baseline.
111
- const baseline = foldClockBaseline(agent.session.ownEvents());
124
+ const owned = advanceOwnedSessionEventCursor(cursors.get(member.memberId), agent.session.id, CLOCK_FOLD, agent.session.ownEvents(), agent.session.inheritedEventCount);
125
+ cursors.set(member.memberId, owned);
126
+ const baseline = owned.cursor.value;
112
127
  // Turn-first-step always samples; later steps sample only at the refresh
113
128
  // interval, so a quick tool-dense turn stays at one line.
114
129
  if (!shouldSampleClock(step, now, baseline, refreshIntervalMs))
@@ -0,0 +1,56 @@
1
+ function escapeRegExp(value) {
2
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
4
+ /**
5
+ * Character ranges the scanner must not read as mentions: fenced code blocks
6
+ * and inline code spans. Authors quote handles there to talk *about* a name
7
+ * rather than call it, and resolving those would notify the wrong Member.
8
+ * The alternation tries the fenced form first so a block is never mistaken for
9
+ * a run of inline spans.
10
+ */
11
+ function codeRanges(body) {
12
+ const ranges = [];
13
+ for (const match of body.matchAll(/```[\s\S]*?```|`[^`\n]*`/g)) {
14
+ const start = match.index ?? 0;
15
+ ranges.push([start, start + match[0].length]);
16
+ }
17
+ return ranges;
18
+ }
19
+ const ALL_MARKER = /(?<![\p{L}\p{N}_@])@all(?=$|[^\p{L}\p{N}_])/iu;
20
+ /**
21
+ * Resolve the `@Handle` mentions authored in one Message body. Matching is
22
+ * deliberately the same shape the Client renders with — case-insensitive, on
23
+ * Unicode word boundaries, longest handle first — so a name that renders as a
24
+ * chip is the same name that delivers a notification.
25
+ *
26
+ * Callers pass the candidates reachable in the Message's Channel, so a name
27
+ * that resolves here is already an addressable target; handles the Channel
28
+ * cannot reach simply stay prose. Writing `@` is required: bare handles are
29
+ * ordinary words, and treating them as mentions would notify on every
30
+ * incidental name-drop.
31
+ *
32
+ * `sender` is excluded because a Message never mentions its own author, which
33
+ * also keeps the result usable as a recipient set without further filtering.
34
+ */
35
+ export function resolveBodyMentions(body, candidates, sender) {
36
+ const usable = candidates.filter(candidate => candidate.memberId !== sender && candidate.handle.trim() !== '');
37
+ const excluded = codeRanges(body);
38
+ const inCode = (index) => excluded.some(([start, end]) => index >= start && index < end);
39
+ const allMarker = ALL_MARKER.exec(body);
40
+ const all = allMarker !== null && !inCode(allMarker.index);
41
+ if (usable.length === 0)
42
+ return { memberIds: Object.freeze([]), all };
43
+ // Longest first so `@Reeves` never resolves as `@Reeve` plus a stray letter.
44
+ const ordered = [...usable].sort((left, right) => right.handle.length - left.handle.length);
45
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}_@])@(?:${ordered.map(candidate => escapeRegExp(candidate.handle)).join('|')})(?=$|[^\\p{L}\\p{N}_])`, 'giu');
46
+ const matched = new Set();
47
+ for (const match of body.matchAll(pattern)) {
48
+ if (inCode(match.index ?? 0))
49
+ continue;
50
+ const written = match[0].slice(1).toLowerCase();
51
+ const hit = ordered.find(candidate => candidate.handle.toLowerCase() === written);
52
+ if (hit !== undefined)
53
+ matched.add(hit.memberId);
54
+ }
55
+ return { memberIds: Object.freeze(usable.filter(candidate => matched.has(candidate.memberId)).map(candidate => candidate.memberId)), all };
56
+ }
@@ -18,6 +18,26 @@ import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
18
18
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
19
19
  import { CONTEXT_PRESSURE_NOTICE_SUMMARY } from "./context-management.js";
20
20
  import { AGENT_TEAM_PLUGIN_ID } from "./context-source.js";
21
+ import { advanceOwnedSessionEventCursor } from "./session-event-cursor.js";
22
+ /**
23
+ * Whether this Session's own span already carries the pressure notice: either
24
+ * surfaced as a `user/message` or still queued in a durable
25
+ * `agent/inbox/spliced` insert. Expressed as a fold rather than a scan because
26
+ * the answer is monotone — once delivered it stays delivered for the
27
+ * generation — so the cursor only ever has to read events it has not seen.
28
+ */
29
+ export const PRESSURE_NOTICE_FOLD = {
30
+ start: false,
31
+ step: (delivered, event) => {
32
+ if (delivered)
33
+ return true;
34
+ if (event.type === 'user/message' && isPressureNotice(event.data))
35
+ return true;
36
+ if (event.type === 'agent/inbox/spliced' && event.data.inserted.some(isPressureNotice))
37
+ return true;
38
+ return false;
39
+ },
40
+ };
21
41
  /** One pressure-notice text; concise, structured, and inside the reserve. */
22
42
  export function contextPressureNoticeText(input) {
23
43
  const claims = input.activeClaims.length === 0 ? 'none' : input.activeClaims.join(', ');
@@ -41,6 +61,14 @@ export class PressurePolicyCoordinator {
41
61
  * Process-only by design: a restart re-earns one sequence per chain.
42
62
  */
43
63
  overflowRetries = new Map();
64
+ /**
65
+ * Whether the one-shot notice was already delivered, folded incrementally per
66
+ * Member. The scan below is a monotone "has this ever happened" fold over the
67
+ * Session's own events, so a cursor can replace re-scanning the whole log on
68
+ * every step; identity guarding falls back to a cold fold when the Member's
69
+ * Session changed under the entry.
70
+ */
71
+ noticeSeen = new Map();
44
72
  disposed = false;
45
73
  constructor(options) {
46
74
  this.options = options;
@@ -55,14 +83,10 @@ export class PressurePolicyCoordinator {
55
83
  * rollover starts a fresh Session whose own event span has no notice yet,
56
84
  * which is exactly the documented re-arm.
57
85
  */
58
- noticeDelivered(agent) {
59
- for (const event of agent.session.ownEvents()) {
60
- if (event.type === 'user/message' && isPressureNotice(event.data))
61
- return true;
62
- if (event.type === 'agent/inbox/spliced' && event.data.inserted.some(isPressureNotice))
63
- return true;
64
- }
65
- return false;
86
+ noticeDelivered(agent, memberId) {
87
+ const owned = advanceOwnedSessionEventCursor(this.noticeSeen.get(memberId), agent.session.id, PRESSURE_NOTICE_FOLD, agent.session.ownEvents(), agent.session.inheritedEventCount);
88
+ this.noticeSeen.set(memberId, owned);
89
+ return owned.cursor.value;
66
90
  }
67
91
  dispose() {
68
92
  this.disposed = true;
@@ -98,7 +122,7 @@ export class PressurePolicyCoordinator {
98
122
  const outcome = await this.enforceHardLimit(agent, member.memberId, member.sessionId, signal);
99
123
  return outcome ? { kind: 'continue' } : { kind: 'reject' };
100
124
  }
101
- if (usageTokens >= handoffAt && !this.noticeDelivered(agent)) {
125
+ if (usageTokens >= handoffAt && !this.noticeDelivered(agent, member.memberId)) {
102
126
  const notice = createUserMessage({
103
127
  content: [{ type: 'text', text: contextPressureNoticeText({
104
128
  usageTokens, handoffAt, hardLimit,
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Incremental Session-log fold with an identity guard.
3
+ *
4
+ * Several Host projections are pure left folds over one Member Session's own
5
+ * events: context intent, the clock baseline, and the one-shot pressure
6
+ * notice. Folding the whole log on every event makes each of them cost
7
+ * O(session length) per step, so a long Session gets steadily slower. This
8
+ * module keeps the fold value together with how far it has consumed the log,
9
+ * so a step folds only the tail that arrived since — the same idiom the
10
+ * Harness Session itself uses for its own derived folds.
11
+ *
12
+ * The module is deliberately Session-agnostic: it takes a plain event array
13
+ * plus the log offset that array starts at, so a caller (including a
14
+ * measurement harness) can drive it without constructing a Session.
15
+ *
16
+ * Correctness rests on the log being append-only, which it is: Session logs
17
+ * have no truncation API, `seq` is the log index, and `seq === log.length` is
18
+ * a documented contiguity contract. A Session adopts a different log only by
19
+ * being constructed from another seed, which replaces the whole slice rather
20
+ * than editing one position.
21
+ *
22
+ * The guard covers the observable ways that contract can break: a changed
23
+ * `logFrom`, a shorter log, a different event type at the anchor. Each
24
+ * re-folds from `logFrom`, so a fork, resume, or rollover degrades to the cost
25
+ * of a cold fold and never to a wrong picture.
26
+ *
27
+ * Named residual: the guard anchors on `(seq, type)`, so an event replaced in
28
+ * place by another of the SAME type at the SAME seq is not detected, and the
29
+ * cursor would keep its old value and fold only the tail. Reaching that state
30
+ * requires a caller to violate the append-only contract above — reading one
31
+ * Session's cursor against a log someone rewrote in place. A content-sensitive
32
+ * anchor would not close it either: only the anchor position is re-read, so a
33
+ * swap at any earlier folded position passes a boundary digest just as
34
+ * silently, while costing a payload digest on every advance. Closing it
35
+ * properly means fingerprinting every folded event, which is the full refold
36
+ * this module exists to avoid; if the contract ever changes, that is the fix.
37
+ * @module @wowyuarm/dsh-agent-team/session-event-cursor
38
+ */
39
+ /**
40
+ * Open a cursor for a log slice that starts at `logFrom` (a Session's
41
+ * `inheritedEventCount` for its own events, or 0 for a full log). Nothing is
42
+ * consumed yet, so the first {@link advanceSessionEventCursor} folds the whole
43
+ * slice.
44
+ */
45
+ export function initSessionEventCursor(fold, logFrom = 0) {
46
+ return { value: fold.start, foldedThrough: logFrom, logFrom, anchor: null };
47
+ }
48
+ /**
49
+ * Fold the log tail that arrived since the cursor stopped and return the
50
+ * advanced cursor; the same reference comes back when there is nothing new.
51
+ *
52
+ * `events` is the slice starting at `logFrom`, and `logEnd` is the EXCLUSIVE
53
+ * end offset of that slice in the owning log — the same coordinate space as
54
+ * `logFrom`, not `events.length`. An inherited Session's own events start at
55
+ * its `inheritedEventCount`, so for a 14-event log with 10 inherited events
56
+ * the slice is `events[0..4]` spanning `logFrom = 10` to `logEnd = 14`. This
57
+ * mirrors the Session's own `snapshotEvents(fromSeq, toSeqExclusive)`.
58
+ *
59
+ * Both offsets are passed explicitly rather than read from the cursor so the
60
+ * caller states the log it is folding, which is also what makes the fallback
61
+ * below decidable.
62
+ */
63
+ export function advanceSessionEventCursor(cursor, events, logFrom, logEnd, fold) {
64
+ if (logEnd !== logFrom + events.length) {
65
+ // The slice and the log it claims to describe disagree, so the tail it is
66
+ // missing is indistinguishable from events that were never there. Refuse
67
+ // rather than fold a shape the caller did not state.
68
+ throw new RangeError(`session event cursor: ${events.length} events cannot span [${logFrom}, ${logEnd})`);
69
+ }
70
+ // Resume only from a position the log can still justify. Every other case
71
+ // re-folds from `logFrom`: one path handles truncation, a changed fork
72
+ // prefix, and a rebuilt log alike, so no caller has to classify them.
73
+ const resumable = cursor.logFrom === logFrom
74
+ && cursor.foldedThrough >= logFrom
75
+ && cursor.foldedThrough <= logEnd
76
+ && (cursor.foldedThrough === logFrom || anchorMatches(cursor, events));
77
+ const from = resumable ? cursor.foldedThrough : logFrom;
78
+ if (from === logEnd)
79
+ return cursor;
80
+ let value = resumable ? cursor.value : fold.start;
81
+ for (let index = from; index < logEnd; index += 1)
82
+ value = fold.step(value, events[index - logFrom]);
83
+ return { value, foldedThrough: logEnd, logFrom, anchor: anchorOf(events[logEnd - 1 - logFrom]) };
84
+ }
85
+ /**
86
+ * Whether the event the cursor stopped on still occupies that position with
87
+ * the type it had. The position is checked as well as the type because `seq`
88
+ * is a log index: an event that moved is a different event.
89
+ */
90
+ function anchorMatches(cursor, events) {
91
+ const anchor = cursor.anchor;
92
+ if (anchor === null)
93
+ return false;
94
+ const seen = events[anchor.seq - cursor.logFrom];
95
+ return seen !== undefined && seen.seq === anchor.seq && seen.type === anchor.type;
96
+ }
97
+ /** The identity a cursor anchors on when it stops at the last consumed event. */
98
+ function anchorOf(event) {
99
+ if (event === undefined)
100
+ return null;
101
+ return { seq: event.seq, type: event.type };
102
+ }
103
+ /**
104
+ * Advance one owner's fold and answer with the entry to store back, so every
105
+ * caller keeps the same one-entry-per-owner invariant.
106
+ *
107
+ * `events` is that Session's whole own-events slice starting at `logFrom` (its
108
+ * `inheritedEventCount`), so the slice's end is the log's end. The cursor is
109
+ * resumed only while `owned` still belongs to `sessionId`; every other case
110
+ * starts cold at `logFrom`, which is the same degradation the cursor's own
111
+ * guard performs for a rewritten log.
112
+ */
113
+ export function advanceOwnedSessionEventCursor(owned, sessionId, fold, events, logFrom) {
114
+ const cursor = owned !== undefined && owned.sessionId === sessionId ? owned.cursor : initSessionEventCursor(fold, logFrom);
115
+ return { sessionId, cursor: advanceSessionEventCursor(cursor, events, logFrom, logFrom + events.length, fold) };
116
+ }
@@ -266,11 +266,6 @@ function physicalParentSession(rows) {
266
266
  const parent = rows[0]?.parentSession;
267
267
  return typeof parent === 'string' ? parent : undefined;
268
268
  }
269
- /**
270
- * The startup remediation pass. Never throws to its caller: a failed or
271
- * partial walk logs and leaves the completion cache unrecorded, so the next
272
- * start retries exactly the Members that need it.
273
- */
274
269
  export class SessionRemediation {
275
270
  ctx;
276
271
  persistence;
@@ -303,29 +298,43 @@ export class SessionRemediation {
303
298
  for (const member of members) {
304
299
  if (member.state !== 'enabled')
305
300
  continue;
306
- if (this.cacheStillValid(member))
301
+ const outcome = await this.remediateMember(member);
302
+ if (outcome.cacheHit)
307
303
  continue;
308
304
  walked += 1;
309
- let repairedCount = 0;
310
- let untouchedCount = 0;
311
- try {
312
- const summary = await this.remediateLineage(member);
313
- repairedCount = summary.repaired;
314
- untouchedCount = summary.untouched;
315
- }
316
- catch (error) {
317
- // Unexpected per-Member failure: log and leave the cache unrecorded.
318
- this.ctx.logger.warn(`agent-team: legacy Session remediation for member '${member.handle}' did not complete: ${error instanceof Error ? error.message : String(error)}`);
319
- continue;
320
- }
321
- repaired += repairedCount;
322
- untouched += untouchedCount;
323
- await this.recordCompletion(member);
305
+ repaired += outcome.repaired;
306
+ untouched += outcome.untouched;
324
307
  }
325
308
  if (walked > 0) {
326
309
  this.ctx.logger.info(`agent-team: legacy Session remediation walked ${walked} member lineage(s): ${repaired} artifact(s) repaired, ${untouched} left untouched`);
327
310
  }
328
311
  }
312
+ /**
313
+ * Remediate one Member's lineage; the bounded in-place heal a restart
314
+ * performs after an activation refused on a session. `completed` with zero
315
+ * repairs is the deterministic nothing-to-do answer (a finished walk found
316
+ * nothing provably this plugin's, or the cache already covered the Member);
317
+ * `completed: false` means the walk itself failed and a later attempt
318
+ * should retry. Never throws.
319
+ */
320
+ async remediateMember(member) {
321
+ if (member.state !== 'enabled' || this.cacheStillValid(member))
322
+ return { repaired: 0, untouched: 0, completed: true, cacheHit: true };
323
+ try {
324
+ const summary = await this.remediateLineage(member);
325
+ // A walk that failed a repair is not evidence of a clean lineage: it is
326
+ // left uncached so the next start retries exactly this Member, and it
327
+ // reports `completed: false` so no caller reads it as the deterministic
328
+ // nothing-to-do answer.
329
+ if (summary.failed === 0)
330
+ await this.recordCompletion(member);
331
+ return { repaired: summary.repaired, untouched: summary.untouched, completed: summary.failed === 0, cacheHit: false };
332
+ }
333
+ catch (error) {
334
+ this.ctx.logger.warn(`agent-team: legacy Session remediation for member '${member.handle}' did not complete: ${error instanceof Error ? error.message : String(error)}`);
335
+ return { repaired: 0, untouched: 0, completed: false, cacheHit: false };
336
+ }
337
+ }
329
338
  /** Whether the cached walk still covers this Member under the current format. */
330
339
  cacheStillValid(member) {
331
340
  const record = this.table?.get(member.memberId);
@@ -360,6 +369,7 @@ export class SessionRemediation {
360
369
  async remediateLineage(member) {
361
370
  let repaired = 0;
362
371
  let untouched = 0;
372
+ let failed = 0;
363
373
  const seen = new Set();
364
374
  let sessionId = member.sessionId;
365
375
  for (let depth = 0; sessionId !== undefined && !seen.has(sessionId) && depth < MAX_LINEAGE_DEPTH; depth += 1) {
@@ -369,9 +379,11 @@ export class SessionRemediation {
369
379
  repaired += 1;
370
380
  else if (outcome.status === 'left-untouched')
371
381
  untouched += 1;
382
+ else if (outcome.status === 'repair-failed')
383
+ failed += 1;
372
384
  sessionId = outcome.parentSession === undefined ? undefined : outcome.parentSession;
373
385
  }
374
- return { repaired, untouched };
386
+ return { repaired, untouched, failed };
375
387
  }
376
388
  /** Remediate one lineage Session: verify readability, and repair when the refusal is this plugin's legacy source shape. */
377
389
  async remediateOne(sessionId, member) {
@@ -409,7 +421,7 @@ export class SessionRemediation {
409
421
  }
410
422
  catch (readError) {
411
423
  this.ctx.logger.warn(`agent-team: could not decode the stored artifact of Session '${sessionId}' (${basename(path)}): ${readError instanceof Error ? readError.message : String(readError)}`);
412
- return { status: 'left-untouched', parentSession: undefined, reason: 'artifact undecodable' };
424
+ return { status: 'repair-failed', parentSession: undefined, reason: 'artifact undecodable' };
413
425
  }
414
426
  const parentSession = physicalParentSession(rows);
415
427
  if (!rows.some(row => containsLegacySource(row))) {
@@ -423,7 +435,7 @@ export class SessionRemediation {
423
435
  }
424
436
  catch (proofError) {
425
437
  this.ctx.logger.warn(`agent-team: Session '${sessionId}' of member '${member.handle}' still refuses after the legacy source rewrite (${proofError instanceof Error ? proofError.message : String(proofError)}); leaving it untouched`);
426
- return { status: 'left-untouched', parentSession, reason: 'residual refusal after rewrite' };
438
+ return { status: 'repair-failed', parentSession, reason: 'residual refusal after rewrite' };
427
439
  }
428
440
  try {
429
441
  const published = await this.publishSibling(path, encodeCurrentArtifact(artifact));
@@ -434,7 +446,7 @@ export class SessionRemediation {
434
446
  }
435
447
  catch (publishError) {
436
448
  this.ctx.logger.warn(`agent-team: could not publish the remediated sibling for Session '${sessionId}' (${basename(path)}): ${publishError instanceof Error ? publishError.message : String(publishError)}`);
437
- return { status: 'left-untouched', parentSession, reason: 'publish failed' };
449
+ return { status: 'repair-failed', parentSession, reason: 'publish failed' };
438
450
  }
439
451
  // Success is proven by the storage layer itself, not by our own writer.
440
452
  try {
@@ -448,7 +460,7 @@ export class SessionRemediation {
448
460
  }
449
461
  catch (verifyError) {
450
462
  this.ctx.logger.warn(`agent-team: the remediated sibling for Session '${sessionId}' was published but still refuses: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}`);
451
- return { status: 'left-untouched', parentSession, reason: 'sibling refused after publish' };
463
+ return { status: 'repair-failed', parentSession, reason: 'sibling refused after publish' };
452
464
  }
453
465
  this.ctx.logger.info(`agent-team: repaired legacy source kinds in Session '${sessionId}' of member '${member.handle}' by publishing a current-format sibling; the original artifact is untouched`);
454
466
  return { status: 'repaired', parentSession };