@wowyuarm/dsh-agent-team 0.1.8 → 0.1.10

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 (81) hide show
  1. package/README.md +4 -11
  2. package/README.zh.md +4 -11
  3. package/package.json +54 -44
  4. package/packages/agent-team/README.md +7 -5
  5. package/packages/agent-team/README.zh.md +7 -4
  6. package/packages/agent-team/lib/attachments.js +14 -4
  7. package/packages/agent-team/lib/context-management.js +382 -0
  8. package/packages/agent-team/lib/context-projection.js +578 -0
  9. package/packages/agent-team/lib/context-source.js +212 -0
  10. package/packages/agent-team/lib/index.js +1272 -76
  11. package/packages/agent-team/lib/ledger.js +333 -44
  12. package/packages/agent-team/lib/member-context.js +6 -2
  13. package/packages/agent-team/lib/member-runtime.js +159 -4
  14. package/packages/agent-team/lib/member-time-context.js +126 -0
  15. package/packages/agent-team/lib/pressure-policy.js +199 -0
  16. package/packages/agent-team/lib/progress-nudge.js +12 -4
  17. package/packages/agent-team/lib/session-remediation.js +481 -0
  18. package/packages/agent-team/lib/spec.js +21 -2
  19. package/packages/agent-team/lib/time-format.js +56 -0
  20. package/packages/agent-team/lib/typert.host.js +168 -54
  21. package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
  22. package/packages/agent-team/lib/typert.remote-client.js +59 -25
  23. package/packages/agent-team/lib/types/attachments.d.ts +1 -1
  24. package/packages/agent-team/lib/types/attachments.d.ts.map +1 -1
  25. package/packages/agent-team/lib/types/context-management.d.ts +147 -0
  26. package/packages/agent-team/lib/types/context-management.d.ts.map +1 -0
  27. package/packages/agent-team/lib/types/context-projection.d.ts +219 -0
  28. package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -0
  29. package/packages/agent-team/lib/types/context-source.d.ts +117 -0
  30. package/packages/agent-team/lib/types/context-source.d.ts.map +1 -0
  31. package/packages/agent-team/lib/types/index.d.ts +300 -2
  32. package/packages/agent-team/lib/types/index.d.ts.map +1 -1
  33. package/packages/agent-team/lib/types/ledger.d.ts +119 -6
  34. package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
  35. package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
  36. package/packages/agent-team/lib/types/member-runtime.d.ts +30 -2
  37. package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
  38. package/packages/agent-team/lib/types/member-time-context.d.ts +83 -0
  39. package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -0
  40. package/packages/agent-team/lib/types/pressure-policy.d.ts +106 -0
  41. package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -0
  42. package/packages/agent-team/lib/types/progress-nudge.d.ts.map +1 -1
  43. package/packages/agent-team/lib/types/session-remediation.d.ts +149 -0
  44. package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -0
  45. package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
  46. package/packages/agent-team/lib/types/time-format.d.ts +28 -0
  47. package/packages/agent-team/lib/types/time-format.d.ts.map +1 -0
  48. package/packages/agent-team/lib/types/types/entities.d.ts +22 -1
  49. package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
  50. package/packages/agent-team/lib/types/types/operations.d.ts +38 -3
  51. package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
  52. package/packages/agent-team/lib/types/types/requests-results.d.ts +58 -1
  53. package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
  54. package/packages/agent-team/preset/team-member/agent.cordis.yml +27 -4
  55. package/packages/client-agent-team/lib/client.js +369 -352
  56. package/packages/client-agent-team/lib/client.js.map +1 -1
  57. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts +1 -2
  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 +61 -41
  60. package/packages/client-agent-team/lib/types/client/TeamMessage.js +2 -2
  61. package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +4 -4
  62. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
  63. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
  64. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +2 -2
  65. package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
  66. package/packages/client-agent-team/lib/types/client/index.js +44 -16
  67. package/packages/client-agent-team/lib/types/client/locales.d.ts +0 -14
  68. package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
  69. package/packages/client-agent-team/lib/types/client/locales.js +0 -14
  70. package/packages/client-agent-team/lib/types/client/slots.d.ts +6 -1
  71. package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
  72. package/packages/tool-agent-team/README.md +11 -6
  73. package/packages/tool-agent-team/README.zh.md +11 -6
  74. package/packages/tool-agent-team/lib/context-tools.js +179 -0
  75. package/packages/tool-agent-team/lib/index.js +369 -58
  76. package/packages/tool-agent-team/lib/types/context-tools.d.ts +16 -0
  77. package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -0
  78. package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
  79. package/packages/agent-team/lib/auto-compaction.js +0 -201
  80. package/packages/agent-team/lib/types/auto-compaction.d.ts +0 -47
  81. package/packages/agent-team/lib/types/auto-compaction.d.ts.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
3
3
  import { SessionLogOffset } from '@deepseek-ai/dsh-session';
4
+ import { memberMemoryDirectoryPath } from "./member-runtime.js";
4
5
  export const name = 'wowyuarm-agent-team-member-context';
5
6
  const MAX_MEMORY_BYTES = 8 * 1024;
6
7
  const BEGIN = '<team-member-private-memory>';
@@ -20,12 +21,15 @@ export function apply(ctx) {
20
21
  const member = host.memberForAgent(agent);
21
22
  if (member === undefined)
22
23
  return decision;
24
+ // The sanitized path is authoritative: activation migrated any legacy
25
+ // colon directory onto it before this member could run a step.
26
+ const memoryPath = memberMemoryDirectoryPath(member);
23
27
  let memory;
24
28
  try {
25
- memory = renderMemberMemory(await readFile(`${member.privateMemoryPath}/memory.md`), member.privateMemoryPath);
29
+ memory = renderMemberMemory(await readFile(`${memoryPath}/memory.md`), memoryPath);
26
30
  }
27
31
  catch (error) {
28
- memory = renderUnavailableMemory(member.privateMemoryPath, error.code === 'ENOENT'
32
+ memory = renderUnavailableMemory(memoryPath, error.code === 'ENOENT'
29
33
  ? 'memory.md is absent; the private memory index is empty.'
30
34
  : 'memory.md is currently unreadable; do not use any earlier private memory context.');
31
35
  }
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * @module @wowyuarm/dsh-agent-team/member-runtime
15
15
  */
16
- import { mkdir, rm, writeFile } from 'node:fs/promises';
16
+ import { mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
17
17
  import { dirname, join, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { scopeOf } from '@deepseek-ai/dsh-scope';
@@ -32,6 +32,9 @@ export const AGENT_TEAM_TOOL_NAMES = Object.freeze([
32
32
  'team_message',
33
33
  'team_claim',
34
34
  'team_view',
35
+ 'context_rollover',
36
+ 'context_checkpoint',
37
+ 'context_timeline',
35
38
  ]);
36
39
  /** Copy a Remote-supplied capability overlay into owned frozen storage. */
37
40
  export function deepCopyCapabilities(capabilities) {
@@ -41,6 +44,31 @@ export function deepCopyCapabilities(capabilities) {
41
44
  ...(capabilities.skills === undefined ? {} : { skills: copyAllow(capabilities.skills.allow) }),
42
45
  };
43
46
  }
47
+ /**
48
+ * Filesystem directory segment for one Member's private memory namespace.
49
+ * The `member:<uuid>` ref is a durable ledger identity and must never appear
50
+ * in a path: Windows rejects `:` in a path segment (NTFS parses it as an
51
+ * Alternate Data Stream separator), which made Member activation fail at its
52
+ * first `mkdir` on Windows (issue #7).
53
+ */
54
+ export function memberMemoryDirectoryName(memberId) {
55
+ return memberId.replaceAll(':', '-');
56
+ }
57
+ /** The sanitized absolute private-memory path for one Member, regardless of what the ledger recorded. */
58
+ export function memberMemoryDirectoryPath(member) {
59
+ // Sanitize per segment: only a segment that actually contains the ledger's
60
+ // member identity colon needs rewriting, and the recorded final segment is
61
+ // the only place it can appear. The drive-letter colon of a Windows
62
+ // absolute prefix is not a member identity and must survive untouched, so
63
+ // a colon-free final segment keeps the recorded path verbatim even when
64
+ // earlier segments carry colons.
65
+ const separator = Math.max(member.privateMemoryPath.lastIndexOf('/'), member.privateMemoryPath.lastIndexOf('\\'));
66
+ const finalSegment = separator === -1 ? member.privateMemoryPath : member.privateMemoryPath.slice(separator + 1);
67
+ if (!finalSegment.includes(':'))
68
+ return member.privateMemoryPath;
69
+ const prefix = separator === -1 ? '' : member.privateMemoryPath.slice(0, separator + 1);
70
+ return prefix + memberMemoryDirectoryName(member.memberId);
71
+ }
44
72
  /** Per-Member runtime state; the four maps dispose together with each Member. */
45
73
  export class MemberRuntime {
46
74
  deps;
@@ -165,10 +193,12 @@ export class MemberRuntime {
165
193
  * scope layer (the traceable-service seam, like the tool restriction):
166
194
  * bundled read-only core skills plus this Member's own private directory,
167
195
  * with the live selection ref that later capability edits swap in place.
196
+ * The sanitized directory is authoritative: activateMember migrated any
197
+ * legacy colon directory onto it before the provider mounted.
168
198
  */
169
199
  mountMemberSkillProvider(member, agentCtx, selection) {
170
200
  this.skillProviderDisposals.set(member.memberId, memberSkills.mountMemberSkillProvider(agentCtx, {
171
- skillsDirectory: join(member.privateMemoryPath, 'skills'),
201
+ skillsDirectory: join(memberMemoryDirectoryPath(member), 'skills'),
172
202
  bundledSkillsDirectory: BUNDLED_SKILLS_DIRECTORY,
173
203
  selection,
174
204
  }));
@@ -183,8 +213,29 @@ export class MemberRuntime {
183
213
  * first-run memory.md scaffold. The Member-private skills directory starts
184
214
  * empty; the per-Member provider scans exactly this root (default roots
185
215
  * excluded).
216
+ *
217
+ * Existing installs recorded the pre-fix colon directory in the ledger, and
218
+ * `privateMemoryPath` is a durable Member fact the renewal path cannot
219
+ * rewrite: when the legacy directory exists it is renamed onto the sanitized
220
+ * path once (same-parent rename, atomic), so existing private memory
221
+ * survives instead of being silently orphaned.
222
+ *
223
+ * A colon-form twin directory is also merged when it exists: a Member may
224
+ * have written files under the ledger identity spelling (the branded
225
+ * `member:<uuid>` ref is what every Team tool result shows, so a
226
+ * hand-assembled path carries the colon). Linux accepts the segment
227
+ * silently — two directories for one Member — while Windows would have
228
+ * failed the write outright. The twin is never a durable-Member-Fact
229
+ * candidate: only the files the Member actually wrote there are worth
230
+ * keeping, and `memory.md` cannot merge, so the sanitized copy always wins
231
+ * and twin-only notes/skills are moved in without overwriting.
186
232
  */
187
- async initializePrivateMemory(path) {
233
+ async initializePrivateMemory(path, legacyPath) {
234
+ if (legacyPath !== undefined && legacyPath !== path)
235
+ await migrateLegacyMemoryDirectory(legacyPath, path);
236
+ const twinPath = twinMemoryDirectoryPath(path);
237
+ if (twinPath !== undefined)
238
+ await mergeTwinMemoryDirectory(twinPath, path);
188
239
  await mkdir(join(path, 'notes'), { recursive: true });
189
240
  await mkdir(join(path, 'skills'), { recursive: true });
190
241
  try {
@@ -197,9 +248,14 @@ export class MemberRuntime {
197
248
  }
198
249
  /** Irreversibly remove one Member: archive its Session and delete its private namespace. */
199
250
  async cleanupRemovedMember(member) {
251
+ // The ledger path may still name the legacy colon directory (never
252
+ // activated after the fix): remove both spellings; rm is force-tolerant
253
+ // of the one that does not exist.
254
+ const sanitized = memberMemoryDirectoryPath(member);
200
255
  const results = await Promise.allSettled([
201
256
  this.deps.ctx.workspaceRegistry.archiveSession(member.sessionId),
202
- rm(member.privateMemoryPath, { recursive: true, force: true }),
257
+ rm(sanitized, { recursive: true, force: true }),
258
+ ...(sanitized === member.privateMemoryPath ? [] : [rm(member.privateMemoryPath, { recursive: true, force: true })]),
203
259
  ]);
204
260
  const failures = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
205
261
  if (failures.length > 0)
@@ -227,3 +283,102 @@ export class MemberRuntime {
227
283
  this.capabilityWarnings.clear();
228
284
  }
229
285
  }
286
+ /**
287
+ * One-time in-place migration of a pre-fix colon-named private memory
288
+ * directory onto its sanitized path. A sanitized target that already exists
289
+ * wins (idempotent across restarts and partially migrated installs); a legacy
290
+ * source that never existed is simply the fresh-install case.
291
+ */
292
+ async function migrateLegacyMemoryDirectory(legacyPath, path) {
293
+ let legacy;
294
+ try {
295
+ legacy = await stat(legacyPath);
296
+ }
297
+ catch (error) {
298
+ if (error.code === 'ENOENT')
299
+ return;
300
+ throw error;
301
+ }
302
+ if (!legacy.isDirectory())
303
+ throw new Error(`legacy Member memory path '${legacyPath}' exists but is not a directory`);
304
+ try {
305
+ await stat(path);
306
+ return; // Sanitized directory already present: migration already done or a new install.
307
+ }
308
+ catch (error) {
309
+ if (error.code !== 'ENOENT')
310
+ throw error;
311
+ }
312
+ await rename(legacyPath, path);
313
+ }
314
+ /** The colon-form twin path of one sanitized Member memory directory, when the segment form admits one. */
315
+ function twinMemoryDirectoryPath(sanitizedPath) {
316
+ const separator = Math.max(sanitizedPath.lastIndexOf('/'), sanitizedPath.lastIndexOf('\\'));
317
+ const prefix = separator === -1 ? '' : sanitizedPath.slice(0, separator + 1);
318
+ const finalSegment = separator === -1 ? sanitizedPath : sanitizedPath.slice(separator + 1);
319
+ // Only the exact sanitized identity segment (`member-<uuid>`) has a colon
320
+ // twin (`member:<uuid>`); any other segment shape has no identity mapping.
321
+ if (!/^member-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(finalSegment))
322
+ return undefined;
323
+ return `${prefix}member:${finalSegment.slice('member-'.length)}`;
324
+ }
325
+ /** The colon-twin trace name for one conflicting file: `note.md` → `note.colon-twin.md`. */
326
+ function colonTwinTraceName(name) {
327
+ const dot = name.lastIndexOf('.');
328
+ return dot <= 0 ? `${name}.colon-twin` : `${name.slice(0, dot)}.colon-twin${name.slice(dot)}`;
329
+ }
330
+ /**
331
+ * Merge a hand-created colon-form twin directory into the sanitized Member
332
+ * memory directory. Unlike the ledger legacy migration this is a merge, not a
333
+ * rename: the sanitized directory is the Member's live root (the injected
334
+ * paths and the skill provider point at it), so on a same-path conflict the
335
+ * live root's copy wins and the twin's losing copy is preserved beside it
336
+ * under a `.colon-twin` name instead of being silently discarded. Twin-only
337
+ * files move in under their own names; the emptied twin directory is then
338
+ * removed so the drift cannot silently recur.
339
+ */
340
+ async function mergeTwinMemoryDirectory(twinPath, path) {
341
+ let twin;
342
+ try {
343
+ twin = await stat(twinPath);
344
+ }
345
+ catch (error) {
346
+ if (error.code !== 'ENOENT')
347
+ throw error;
348
+ return;
349
+ }
350
+ if (!twin.isDirectory())
351
+ return;
352
+ await mergeDirectoryContents(twinPath, path);
353
+ await rm(twinPath, { recursive: true, force: true });
354
+ }
355
+ /** Recursively move every twin file into the live root; an existing live file wins, its twin copy traced beside it. */
356
+ async function mergeDirectoryContents(source, target) {
357
+ await mkdir(target, { recursive: true });
358
+ for (const entry of await readdir(source, { withFileTypes: true })) {
359
+ const sourcePath = join(source, entry.name);
360
+ const targetPath = join(target, entry.name);
361
+ if (entry.isDirectory()) {
362
+ await mergeDirectoryContents(sourcePath, targetPath);
363
+ }
364
+ else if (entry.isFile()) {
365
+ // rename(2) silently REPLACES an existing target on POSIX (the EEXIST
366
+ // error only exists on Windows, where the colon twin cannot exist at
367
+ // all), so a collision must be detected, not caught: probe the target
368
+ // first. Activation is the single serialized writer, so the
369
+ // probe-to-rename window has no concurrent writer to race.
370
+ let liveFilePresent = true;
371
+ try {
372
+ await stat(targetPath);
373
+ }
374
+ catch (error) {
375
+ if (error.code !== 'ENOENT')
376
+ throw error;
377
+ liveFilePresent = false;
378
+ }
379
+ // The live root stays authoritative; the twin's losing copy stays
380
+ // traceable beside it instead of being silently discarded.
381
+ await rename(sourcePath, liveFilePresent ? join(target, colonTwinTraceName(entry.name)) : targetPath);
382
+ }
383
+ }
384
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Team Member turn-level clock context.
3
+ *
4
+ * The first model step of every eligible Team Member turn appends one
5
+ * durable, source-attributed clock snapshot: the current instant in the
6
+ * fixed Team coordination zone (UTC+8), the elapsed time since the
7
+ * preceding model-visible event, and the ordering authority note. Later
8
+ * steps of the same turn stay quiet unless the turn runs longer than the
9
+ * refresh interval, in which case one snapshot lands per elapsed interval —
10
+ * a tool-dense turn of quick steps produces exactly one line, while a turn
11
+ * that outlives the interval still shows its real span. The snapshot is an
12
+ * observation, never ledger authority: sequence and revision, not
13
+ * wall-clock time, order Team facts.
14
+ *
15
+ * This plugin deliberately does not mount the shipped
16
+ * `@deepseek-ai/dsh-time-context`: its browser-zone policy asks the model to
17
+ * confirm dates with the user whenever a request carries no unique browser
18
+ * zone, which is the normal case for background Member wakes (Inbox, DM,
19
+ * recovery, continuation). The Team coordination zone is fixed instead. If
20
+ * the harness grows a public non-browser/canonical-zone policy, retire this
21
+ * row in favor of configuring that plugin.
22
+ *
23
+ * State is folded from the Member Session's own events — the same
24
+ * manual-fold pattern the Host's context projection uses — so restart,
25
+ * request reconstruction, and compaction all derive identical baselines
26
+ * without a second durable store.
27
+ * @module @wowyuarm/dsh-agent-team/member-time-context
28
+ */
29
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
30
+ import { formatTeamDuration, formatTeamTimestamp } from "./time-format.js";
31
+ export const name = 'wowyuarm-agent-team-member-time-context';
32
+ /** Default minimum spacing between two snapshots within one turn, in ms. */
33
+ export const CLOCK_REFRESH_INTERVAL_MS = 1_800_000;
34
+ function emptyBaseline() {
35
+ return { lastMessageTime: null, lastInjectionTime: null, lastTurnInjectionTime: null, openTurn: -1 };
36
+ }
37
+ /**
38
+ * Fold one session event into the clock baseline. Uninterested events return
39
+ * the same state reference.
40
+ * @internal exported for tests.
41
+ */
42
+ export function applyClockEvent(state, event) {
43
+ switch (event.type) {
44
+ case 'turn/start':
45
+ return event.data.turn === state.openTurn ? state
46
+ : { ...state, lastTurnInjectionTime: null, openTurn: event.data.turn };
47
+ case 'turn/end':
48
+ return state.openTurn === -1 ? state : { ...state, lastTurnInjectionTime: null, openTurn: -1 };
49
+ case 'user/message': {
50
+ const source = event.data.source;
51
+ const injected = source.kind === 'plugin' && source.plugin === name;
52
+ const withMessage = state.lastMessageTime === event.time ? state : { ...state, lastMessageTime: event.time };
53
+ if (!injected)
54
+ return withMessage;
55
+ return { ...withMessage, lastInjectionTime: event.time, lastTurnInjectionTime: event.time };
56
+ }
57
+ case 'assistant/message':
58
+ case 'tool/result':
59
+ return state.lastMessageTime === event.time ? state : { ...state, lastMessageTime: event.time };
60
+ default:
61
+ return state;
62
+ }
63
+ }
64
+ /** Fold a whole event log into the clock baseline. @internal exported for tests. */
65
+ export function foldClockBaseline(events) {
66
+ let state = emptyBaseline();
67
+ for (const event of events)
68
+ state = applyClockEvent(state, event);
69
+ return state;
70
+ }
71
+ /**
72
+ * Whether this step should append a clock snapshot: the first step of a turn
73
+ * always does (every wake starts with a fresh instant), and a later step
74
+ * does only when the turn has run longer than the refresh interval since
75
+ * the last landed snapshot. Skipped steps produce nothing and never
76
+ * backfill — their span folds into the next snapshot's elapsed.
77
+ * @internal exported for tests.
78
+ */
79
+ export function shouldSampleClock(step, now, baseline, refreshIntervalMs) {
80
+ if (step === 1)
81
+ return true;
82
+ return baseline.lastTurnInjectionTime === null || now - baseline.lastTurnInjectionTime >= refreshIntervalMs;
83
+ }
84
+ /** Render one durable clock snapshot text. @internal exported for tests. */
85
+ export function renderClockSnapshot(input) {
86
+ // A wall-clock rollback clamps elapsed to 0s without rewriting history.
87
+ const elapsed = input.previous === undefined ? 'unavailable' : formatTeamDuration(input.now - input.previous);
88
+ const baseline = input.step === 1 ? 'model-visible event' : 'step context';
89
+ return `Team clock sampled while preparing turn ${input.turn}, step ${input.step}: ${formatTeamTimestamp(new Date(input.now).toISOString())}\n`
90
+ + `Elapsed since the preceding ${baseline}: ${elapsed}.\n`
91
+ + 'Team collaboration timestamps use UTC+8. Sequence and revision, not wall-clock time, determine ordering and concurrency.';
92
+ }
93
+ export function apply(ctx, config = {}) {
94
+ const refreshIntervalMs = config.refreshIntervalMs ?? CLOCK_REFRESH_INTERVAL_MS;
95
+ ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
96
+ const decision = await next();
97
+ if (decision.kind === 'reject' || signal.aborted)
98
+ return decision;
99
+ // The Host service is resolved at step time, never through plugin inject
100
+ // (same reason as member-context: the row mounts while the Host itself
101
+ // is still restoring Members).
102
+ const host = ctx.get('agentTeam');
103
+ if (host === undefined)
104
+ return decision;
105
+ if (host.memberForAgent(agent) === undefined)
106
+ return decision;
107
+ 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());
112
+ // Turn-first-step always samples; later steps sample only at the refresh
113
+ // interval, so a quick tool-dense turn stays at one line.
114
+ if (!shouldSampleClock(step, now, baseline, refreshIntervalMs))
115
+ return decision;
116
+ const previous = step === 1
117
+ ? baseline.lastMessageTime ?? undefined
118
+ : baseline.lastTurnInjectionTime ?? undefined;
119
+ const text = renderClockSnapshot({ now, turn, step, previous });
120
+ const message = createUserMessage({
121
+ content: [{ type: 'text', text }],
122
+ source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] },
123
+ });
124
+ return { kind: 'enter', messages: [...decision.messages, message] };
125
+ }, { prepend: true });
126
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Team-owned context pressure policy.
3
+ *
4
+ * The Team preset mounts `compaction-basic` with `auto: false`; this
5
+ * coordinator owns both policy entry points instead — the proactive handoff
6
+ * notice near the effective handoff budget and the forced CompactionEngine
7
+ * reduction before any request at or above the effective hard limit — while
8
+ * reusing the public CompactionEngine implementation. The hard-limit
9
+ * translation lives in exactly one method; no caller re-derives it.
10
+ *
11
+ * Fail-closed rule: at the hard limit, a compaction that no-ops, throws, is
12
+ * cancelled, or does not advance the durable surface must block the model
13
+ * request (reject the step) rather than knowingly submit over the Team limit.
14
+ * A blocked Member keeps its log and reports a recoverable error.
15
+ * @module @wowyuarm/dsh-agent-team/pressure-policy
16
+ */
17
+ import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
18
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
19
+ import { CONTEXT_PRESSURE_NOTICE_SUMMARY } from "./context-management.js";
20
+ import { AGENT_TEAM_PLUGIN_ID } from "./context-source.js";
21
+ /** One pressure-notice text; concise, structured, and inside the reserve. */
22
+ export function contextPressureNoticeText(input) {
23
+ const claims = input.activeClaims.length === 0 ? 'none' : input.activeClaims.join(', ');
24
+ const jobs = input.runningJobs.length === 0 ? 'none' : `${input.runningJobs.length} running (collect or stop them before switching)`;
25
+ return [
26
+ `Context pressure: ${input.usageTokens} tokens measured; the handoff budget is ${input.handoffAt} and the hard limit is ${input.hardLimit}.`,
27
+ `Active Claims: ${claims}. Owner jobs: ${jobs}.`,
28
+ 'Finish the current atomic action, then call context_rollover with a handoff covering your objective, verified facts, and external side effects — a fresh context is the default path. Record anything durable in your private memory/notes first.',
29
+ ].join(' ');
30
+ }
31
+ /** Whether one user message is this policy's one-shot pressure notice. */
32
+ function isPressureNotice(message) {
33
+ const source = message.source;
34
+ return source?.plugin === AGENT_TEAM_PLUGIN_ID
35
+ && source?.summary === CONTEXT_PRESSURE_NOTICE_SUMMARY;
36
+ }
37
+ export class PressurePolicyCoordinator {
38
+ options;
39
+ /**
40
+ * Retry budget per agent for the current provider-overflow sequence.
41
+ * Process-only by design: a restart re-earns one sequence per chain.
42
+ */
43
+ overflowRetries = new Map();
44
+ disposed = false;
45
+ constructor(options) {
46
+ this.options = options;
47
+ }
48
+ /**
49
+ * The one-shot pressure notice is durable Session evidence, not process
50
+ * state: a `CONTEXT_PRESSURE_NOTICE_SUMMARY` notice already surfaced as a
51
+ * `user/message`, or still queued in a durable `agent/inbox/spliced`
52
+ * insert (a steered notice surfaces only at the next step boundary, and a
53
+ * Host restart replays the splice before surfacing), marks the current
54
+ * generation as already notified. A resume or restart stays quiet; a
55
+ * rollover starts a fresh Session whose own event span has no notice yet,
56
+ * which is exactly the documented re-arm.
57
+ */
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;
66
+ }
67
+ dispose() {
68
+ this.disposed = true;
69
+ this.overflowRetries.clear();
70
+ }
71
+ /** A successful assistant response ends any open overflow-recovery sequence. */
72
+ onAssistantMessage(agent) {
73
+ this.overflowRetries.delete(agent);
74
+ }
75
+ /**
76
+ * Pre-step policy for one Member agent: below the handoff budget nothing
77
+ * happens; at the handoff budget one structured notice per generation is
78
+ * steered into the running turn; at the hard limit the request is forced
79
+ * through compaction first and fails closed when that cannot be proven.
80
+ * Returns the decision for the step: `continue` forwards, `reject` blocks.
81
+ */
82
+ async onPreStep(agent, signal) {
83
+ if (this.disposed || signal.aborted)
84
+ return { kind: 'continue' };
85
+ const member = this.options.memberForAgent(agent);
86
+ if (member === undefined)
87
+ return { kind: 'continue' };
88
+ const limits = await this.options.limitsForAgent(agent);
89
+ if (limits === undefined) {
90
+ // A missing route capacity must be explicit, never an accidental
91
+ // unlimited policy: reject the step with a recoverable diagnostic.
92
+ const diagnostic = 'context pressure policy: the routed model capacity is unknown; refusing to forward a request without a bounded context budget';
93
+ this.options.failed(member.memberId, member.sessionId, diagnostic);
94
+ return { kind: 'reject' };
95
+ }
96
+ const { usageTokens, hardLimit, handoffAt } = limits;
97
+ if (usageTokens >= hardLimit) {
98
+ const outcome = await this.enforceHardLimit(agent, member.memberId, member.sessionId, signal);
99
+ return outcome ? { kind: 'continue' } : { kind: 'reject' };
100
+ }
101
+ if (usageTokens >= handoffAt && !this.noticeDelivered(agent)) {
102
+ const notice = createUserMessage({
103
+ content: [{ type: 'text', text: contextPressureNoticeText({
104
+ usageTokens, handoffAt, hardLimit,
105
+ activeClaims: this.options.activeClaimLabels(member.memberId),
106
+ runningJobs: this.options.runningJobLabels(member.memberId),
107
+ }) }],
108
+ source: { kind: 'plugin', plugin: AGENT_TEAM_PLUGIN_ID, form: 'notice', summary: CONTEXT_PRESSURE_NOTICE_SUMMARY },
109
+ });
110
+ try {
111
+ agent.steer(notice);
112
+ }
113
+ catch (error) {
114
+ this.options.log(`context pressure notice failed: ${error instanceof Error ? error.message : String(error)} (member ${member.memberId})`);
115
+ }
116
+ return { kind: 'notice' };
117
+ }
118
+ return { kind: 'continue' };
119
+ }
120
+ /**
121
+ * Provider-overflow recovery: one bounded compact-and-retry sequence per
122
+ * open failure chain. Returns whether the request may retry once.
123
+ */
124
+ async onRequestError(agent, failure, signal) {
125
+ if (this.disposed || signal.aborted)
126
+ return false;
127
+ if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE)
128
+ return false;
129
+ const member = this.options.memberForAgent(agent);
130
+ if (member === undefined)
131
+ return false;
132
+ const retries = this.overflowRetries.get(agent) ?? 0;
133
+ if (retries >= 1)
134
+ return false;
135
+ const engine = this.options.compactionForAgent(agent);
136
+ if (engine === undefined)
137
+ return false;
138
+ const generation = agent.session.surface.replaceGeneration;
139
+ try {
140
+ await engine.compactIfNeeded(agent, 'context-overflow', signal);
141
+ }
142
+ catch (error) {
143
+ // Durable prune progress before a later summary failure justifies the
144
+ // single retry; cancellation never does.
145
+ if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
146
+ this.overflowRetries.set(agent, retries + 1);
147
+ return true;
148
+ }
149
+ this.options.log(`context-overflow recovery failed: ${error instanceof Error ? error.message : String(error)} (member ${member.memberId})`);
150
+ return false;
151
+ }
152
+ if (signal.aborted || agent.session.surface.replaceGeneration <= generation)
153
+ return false;
154
+ this.overflowRetries.set(agent, retries + 1);
155
+ return true;
156
+ }
157
+ /**
158
+ * The one Team hard-limit translation: force a CompactionEngine reduction
159
+ * in the current Agent/Session and prove it advanced the durable surface
160
+ * (or measurably reduced pressure) before continuing. Background jobs are
161
+ * untouched — compaction never cancels or discards them.
162
+ * @returns whether the request may proceed.
163
+ */
164
+ async enforceHardLimit(agent, memberId, sessionId, signal) {
165
+ const engine = this.options.compactionForAgent(agent);
166
+ if (engine === undefined) {
167
+ const diagnostic = 'context hard limit reached and compaction is unavailable in the Member scope; the request was blocked';
168
+ this.options.failed(memberId, sessionId, diagnostic);
169
+ return false;
170
+ }
171
+ const meter = agent.ctx.get('tokenMeter');
172
+ const before = meter?.measure(agent.session)?.totalTokens ?? Number.POSITIVE_INFINITY;
173
+ const generation = agent.session.surface.replaceGeneration;
174
+ let result;
175
+ try {
176
+ result = await engine.compactIfNeeded(agent, 'context-overflow', signal);
177
+ }
178
+ catch (error) {
179
+ const diagnostic = `context hard limit compaction failed: ${error instanceof Error ? error.message : String(error)}; the request was blocked`;
180
+ this.options.failed(memberId, sessionId, diagnostic);
181
+ return false;
182
+ }
183
+ if (signal.aborted)
184
+ return false;
185
+ const after = meter?.measure(agent.session)?.totalTokens ?? Number.POSITIVE_INFINITY;
186
+ const surfaceAdvanced = agent.session.surface.replaceGeneration > generation;
187
+ const pressureReduced = meter === undefined ? false : after < before;
188
+ if (!surfaceAdvanced && !pressureReduced) {
189
+ // No-op or unchanged replacement generation: fail closed rather than
190
+ // knowingly submit over the Team limit.
191
+ const diagnostic = result === null
192
+ ? 'context hard limit reached and no compactable range exists; the request was blocked'
193
+ : 'context hard limit compaction produced no measurable reduction; the request was blocked';
194
+ this.options.failed(memberId, sessionId, diagnostic);
195
+ return false;
196
+ }
197
+ return true;
198
+ }
199
+ }
@@ -22,7 +22,7 @@
22
22
  * @module @wowyuarm/dsh-agent-team/progress-nudge
23
23
  */
24
24
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
25
- const PROGRESS_NUDGE_PLUGIN_ID = '@wowyuarm/dsh-agent-team';
25
+ import { AGENT_TEAM_PLUGIN_ID, isCheckpointContinuationMessage } from "./context-source.js";
26
26
  /** Notice summary for every nudge this Coordinator injects. */
27
27
  export const PROGRESS_NUDGE_NOTICE_SUMMARY = 'Progress visibility reminder';
28
28
  export const PROGRESS_NUDGE_TOOL_CALLS_START = 20;
@@ -39,7 +39,7 @@ export function isPublicCommunicationOperationKind(kind) {
39
39
  /** Whether one message is a nudge notice this module injected. */
40
40
  export function isProgressNudgeNotice(message) {
41
41
  const source = message.source;
42
- return source.kind === 'plugin' && source.plugin === PROGRESS_NUDGE_PLUGIN_ID
42
+ return source.kind === 'plugin' && source.plugin === AGENT_TEAM_PLUGIN_ID
43
43
  && source.form === 'notice' && source.summary === PROGRESS_NUDGE_NOTICE_SUMMARY;
44
44
  }
45
45
  /**
@@ -218,7 +218,7 @@ export class ProgressNudgeCoordinator {
218
218
  const claimTargets = claimDue ? freshClaimTargets : [];
219
219
  const notice = createUserMessage({
220
220
  content: [{ type: 'text', text: nudgeNoticeText(state.silentToolCalls, progressTargets, claimTargets) }],
221
- source: { kind: 'plugin', plugin: PROGRESS_NUDGE_PLUGIN_ID, form: 'notice', summary: PROGRESS_NUDGE_NOTICE_SUMMARY },
221
+ source: { kind: 'plugin', plugin: AGENT_TEAM_PLUGIN_ID, form: 'notice', summary: PROGRESS_NUDGE_NOTICE_SUMMARY },
222
222
  });
223
223
  const pending = {
224
224
  messageId: notice.id,
@@ -268,7 +268,15 @@ export class ProgressNudgeCoordinator {
268
268
  hasBlockingNotice(agent) {
269
269
  for (const message of [...agent.inbox.nextStep, ...agent.inbox.nextTurn]) {
270
270
  const source = message.source;
271
- if (source.kind !== 'plugin' || source.plugin !== PROGRESS_NUDGE_PLUGIN_ID || source.form !== 'notice')
271
+ if (source.kind !== 'plugin' || source.plugin !== AGENT_TEAM_PLUGIN_ID)
272
+ continue;
273
+ // A queued checkpoint continuation is a pending wake that outranks a
274
+ // nudge exactly as the notice families above do. It rides the snapshot
275
+ // form (it must carry its checkpoint ref in a section), so it cannot be
276
+ // recognized by the notice summary check below.
277
+ if (isCheckpointContinuationMessage(message))
278
+ return true;
279
+ if (source.form !== 'notice')
272
280
  continue;
273
281
  if (source.summary !== PROGRESS_NUDGE_NOTICE_SUMMARY)
274
282
  return true;