@try-works/dsh-memory 0.1.19 → 0.1.20

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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +287 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/src/SYSTEM.template.md +311 -311
  8. package/src/capture.js +242 -242
  9. package/src/client/index.js +886 -886
  10. package/src/config.js +57 -57
  11. package/src/consolidate-run.js +132 -132
  12. package/src/consolidate.js +330 -330
  13. package/src/context.js +88 -88
  14. package/src/decisions.js +106 -106
  15. package/src/defaults.js +93 -93
  16. package/src/digest.js +255 -255
  17. package/src/entry.js +179 -179
  18. package/src/eval.js +72 -72
  19. package/src/files.js +60 -60
  20. package/src/fixture.js +56 -56
  21. package/src/forget.js +71 -71
  22. package/src/freshness.js +65 -65
  23. package/src/fts.js +166 -166
  24. package/src/git.js +53 -53
  25. package/src/history.js +226 -226
  26. package/src/host.js +866 -866
  27. package/src/index.js +115 -94
  28. package/src/inject.js +108 -108
  29. package/src/layers.js +178 -178
  30. package/src/ledger.js +19 -19
  31. package/src/map.js +184 -184
  32. package/src/models.js +136 -136
  33. package/src/queue.js +602 -602
  34. package/src/rank.js +31 -31
  35. package/src/recover.js +91 -91
  36. package/src/refresh.js +80 -80
  37. package/src/remember.js +58 -58
  38. package/src/rerank.js +90 -90
  39. package/src/search.js +75 -75
  40. package/src/select.js +57 -57
  41. package/src/session.js +60 -60
  42. package/src/state.js +240 -240
  43. package/src/store.js +128 -128
  44. package/src/summarize.js +195 -195
  45. package/src/system-doc.js +171 -171
  46. package/src/tasks.js +617 -617
  47. package/src/telemetry.js +122 -122
  48. package/src/tools.js +828 -818
  49. package/src/working.js +288 -288
  50. package/src/workspace-key.js +68 -68
  51. package/src/workspace.js +87 -87
package/src/index.js CHANGED
@@ -1,94 +1,115 @@
1
- // dsh-memory plugin entry (Cordis). Config/settings, store lifecycle, the ctx.memory
2
- // service, memory_* tools, and the deep host hooks (capture/consolidate/inject/sync).
3
- import { Config } from './config.js'
4
- import { resolveConfig } from './defaults.js'
5
- import { MemoryStore } from './store.js'
6
- import { search } from './search.js'
7
- import { remember } from './remember.js'
8
- import { associate, listWorkspaces, resolveKeyFor } from './workspace.js'
9
- import { pendingDigests } from './capture.js'
10
- import { migrateToLayered } from './layers.js'
11
- import { writeSystemDoc } from './system-doc.js'
12
- import { readFileSync } from 'node:fs'
13
- import { dirname, join } from 'node:path'
14
- import { fileURLToPath } from 'node:url'
15
-
16
- /** The plugin version, read from its own manifest; never fatal if it cannot be read. */
17
- function readPluginVersion() {
18
- try {
19
- const manifest = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
20
- return JSON.parse(readFileSync(manifest, 'utf8')).version ?? 'unknown'
21
- } catch { return 'unknown' }
22
- }
23
- const PLUGIN_VERSION = readPluginVersion()
24
-
25
- /**
26
- * The capabilities registerTools needs, taken from the host in ONE place.
27
- *
28
- * This used to be an inline object literal, and the wiring test wrote its own COPY of it. A copy
29
- * drifts: the test's version was missing `recover`, so a tool wired to nothing passed every test
30
- * and failed the moment a real session called it. Deriving both from the host object means the
31
- * test cannot describe a wiring the plugin does not have.
32
- */
33
- export function hostExtras(host) {
34
- return {
35
- syncNow: host.syncNow,
36
- consolidate: host.consolidate,
37
- refresh: host.refresh,
38
- status: host.status,
39
- recover: host.recover,
40
- rerankSearch: host.rerankSearch,
41
- workspaceOf: host.workspaceOf,
42
- historyOf: host.historyOf,
43
- injectionMaxChars: host.injectionMaxChars,
44
- tasksNow: host.tasksNow,
45
- tasksRestore: host.tasksRestore,
46
- schedulePath: host.schedulePath,
47
- }
48
- }
49
- export const name = 'dsh-memory'
50
- import { registerTools } from './tools.js'
51
- import { wireHost } from './host.js'
52
-
53
- export const inject = ['tools']
54
- export { Config }
55
-
56
- export function apply(ctx, config = {}) {
57
- const cfg = resolveConfig(config)
58
- const store = new MemoryStore(cfg)
59
-
60
- try {
61
- store.ensureLayout()
62
- // Layered storage landed after the first stores existed: move any entries still sitting
63
- // in a single layer file into per-topic files and rebuild its index. Idempotent.
64
- store.syncGitBlock()
65
- const moved = migrateToLayered(store.root)
66
- if (moved > 0) ctx.logger.info('dsh-memory: moved ' + moved + ' entr(ies) into per-topic files')
67
- // Regenerate the manual for the machinery on every boot, so it can never describe a
68
- // version of the plugin that no longer exists.
69
- writeSystemDoc(store.root, { cfg, version: PLUGIN_VERSION })
70
- } catch (error) { ctx.logger.warn('dsh-memory: could not prepare memory store at ' + store.root + ': ' + (error && error.message)) }
71
-
72
- ctx.provide('memory', {
73
- get root() { return store.root },
74
- status: () => store.status(),
75
- search: (query, opts) => search(store, query, opts),
76
- remember: (opts) => remember(store, opts),
77
- workspaces: () => listWorkspaces(store),
78
- associate: (opts) => associate(store, opts),
79
- resolveKey: (input) => resolveKeyFor(store.root, input),
80
- pending: () => pendingDigests(store).length,
81
- consolidate: (agent, opts) => host.consolidate(agent, opts),
82
- workspaceOf: (agent) => host.workspaceOf(agent),
83
- historyOf: (agent, opts) => host.historyOf(agent, opts),
84
- })
85
-
86
- const host = wireHost(ctx, store, cfg)
87
- registerTools(ctx, store, hostExtras(host))
88
-
89
- ctx.on('settings/document-updated', () => { ctx.logger.debug('dsh-memory: settings changed') })
90
-
91
- ctx.effect(() => { store.dispose() })
92
-
93
- return host
94
- }
1
+ // dsh-memory plugin entry (Cordis). Config/settings, store lifecycle, the ctx.memory
2
+ // service, memory_* tools, and the deep host hooks (capture/consolidate/inject/sync).
3
+ import { Config } from './config.js'
4
+ import { resolveConfig } from './defaults.js'
5
+ import { MemoryStore } from './store.js'
6
+ import { search } from './search.js'
7
+ import { remember } from './remember.js'
8
+ import { associate, listWorkspaces, resolveKeyFor } from './workspace.js'
9
+ import { pendingDigests } from './capture.js'
10
+ import { migrateToLayered } from './layers.js'
11
+ import { writeSystemDoc } from './system-doc.js'
12
+ import { readFileSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ /** The plugin version, read from its own manifest; never fatal if it cannot be read. */
17
+ function readPluginVersion() {
18
+ try {
19
+ const manifest = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
20
+ return JSON.parse(readFileSync(manifest, 'utf8')).version ?? 'unknown'
21
+ } catch { return 'unknown' }
22
+ }
23
+ const PLUGIN_VERSION = readPluginVersion()
24
+
25
+ /**
26
+ * The capabilities registerTools needs, taken from the host in ONE place.
27
+ *
28
+ * This used to be an inline object literal, and the wiring test wrote its own COPY of it. A copy
29
+ * drifts: the test's version was missing `recover`, so a tool wired to nothing passed every test
30
+ * and failed the moment a real session called it. Deriving both from the host object means the
31
+ * test cannot describe a wiring the plugin does not have.
32
+ */
33
+ export function hostExtras(host) {
34
+ return {
35
+ syncNow: host.syncNow,
36
+ consolidate: host.consolidate,
37
+ refresh: host.refresh,
38
+ status: host.status,
39
+ recover: host.recover,
40
+ rerankSearch: host.rerankSearch,
41
+ workspaceOf: host.workspaceOf,
42
+ historyOf: host.historyOf,
43
+ injectionMaxChars: host.injectionMaxChars,
44
+ tasksNow: host.tasksNow,
45
+ tasksRestore: host.tasksRestore,
46
+ schedulePath: host.schedulePath,
47
+ }
48
+ }
49
+ export const name = 'dsh-memory'
50
+ import { registerTools } from './tools.js'
51
+ import { wireHost } from './host.js'
52
+
53
+ export const inject = ['tools']
54
+ export { Config }
55
+
56
+ export function apply(ctx, config = {}) {
57
+ const cfg = resolveConfig(config)
58
+ /**
59
+ * Re-read volatile fields when the loader commits a configuration change.
60
+ *
61
+ * resolveConfig UNWRAPS a volatile reference into a plain value and is called once, here, at
62
+ * load. That snapshot never moves - so a git origin typed into Settings was written to the
63
+ * profile file, committed into the running fiber by the loader, and still never reached this
64
+ * instance: memory_sync answered synced:false with no remote added and no error to explain it.
65
+ *
66
+ * The contract is documented and this code already half-knew it: volatile fields parse to a
67
+ * reference rather than a value, and the cookbook says to read it when STARTING an operation
68
+ * (docs/cookbook/adding-a-settings-card.md). Mutating cfg IN PLACE is what makes that true here,
69
+ * because every consumer reads cfg.<field> at call time instead of capturing it.
70
+ */
71
+ ctx.on('loader/volatile-update', () => {
72
+ try {
73
+ const next = resolveConfig(config)
74
+ for (const key of Object.keys(next)) cfg[key] = next[key]
75
+ } catch (error) {
76
+ ctx.logger.warn('dsh-memory: could not re-read configuration: ' + (error && error.message))
77
+ }
78
+ })
79
+ const store = new MemoryStore(cfg)
80
+
81
+ try {
82
+ store.ensureLayout()
83
+ // Layered storage landed after the first stores existed: move any entries still sitting
84
+ // in a single layer file into per-topic files and rebuild its index. Idempotent.
85
+ store.syncGitBlock()
86
+ const moved = migrateToLayered(store.root)
87
+ if (moved > 0) ctx.logger.info('dsh-memory: moved ' + moved + ' entr(ies) into per-topic files')
88
+ // Regenerate the manual for the machinery on every boot, so it can never describe a
89
+ // version of the plugin that no longer exists.
90
+ writeSystemDoc(store.root, { cfg, version: PLUGIN_VERSION })
91
+ } catch (error) { ctx.logger.warn('dsh-memory: could not prepare memory store at ' + store.root + ': ' + (error && error.message)) }
92
+
93
+ ctx.provide('memory', {
94
+ get root() { return store.root },
95
+ status: () => store.status(),
96
+ search: (query, opts) => search(store, query, opts),
97
+ remember: (opts) => remember(store, opts),
98
+ workspaces: () => listWorkspaces(store),
99
+ associate: (opts) => associate(store, opts),
100
+ resolveKey: (input) => resolveKeyFor(store.root, input),
101
+ pending: () => pendingDigests(store).length,
102
+ consolidate: (agent, opts) => host.consolidate(agent, opts),
103
+ workspaceOf: (agent) => host.workspaceOf(agent),
104
+ historyOf: (agent, opts) => host.historyOf(agent, opts),
105
+ })
106
+
107
+ const host = wireHost(ctx, store, cfg)
108
+ registerTools(ctx, store, hostExtras(host))
109
+
110
+ ctx.on('settings/document-updated', () => { ctx.logger.debug('dsh-memory: settings changed') })
111
+
112
+ ctx.effect(() => { store.dispose() })
113
+
114
+ return host
115
+ }
package/src/inject.js CHANGED
@@ -1,108 +1,108 @@
1
- // Session-start injection text: goal + working head + recent learnings + footer,
2
- // char-capped and scope-aware.
3
- import { existsSync, readFileSync } from 'node:fs'
4
- import { join } from 'node:path'
5
- import { layerDir } from './store.js'
6
- import { buildLedger } from './ledger.js'
7
- import { readLayerTopics } from './layers.js'
8
- import { freshnessMarker, readState } from './state.js'
9
- import { readWorking, workingMarker } from './working.js'
10
- import { relevanceQuery, selectEntries } from './select.js'
11
-
12
- export function buildInjectionText(store, { scope = 'global', key, goal, maxChars = 3600, subagent = false } = {}) {
13
- const dir = layerDir(store.root, scope, key)
14
- // Working memory is the AGENT's intent (task, recent activity, next steps); repository
15
- const ledger = buildLedger(store.root)
16
- const prefix = scope === 'global' ? 'global/' : 'workspaces/' + key + '/'
17
- // The pools are LISTED here and CHOSEN later, because choosing needs the question, and the
18
- // question comes from working memory - which is read below.
19
- const learningPool = ledger.filter((e) => e.file.startsWith(prefix) && e.layer === 'learning')
20
- const decisionPool = ledger.filter((e) => e.file.startsWith(prefix) && e.layer === 'decision')
21
- // state (state.md) is the repository's. Different owners, different lifetimes: a repository
22
- const workingMemory = scope === 'workspace' ? readWorking(store.root, scope, key) : undefined
23
- // What this turn is about, from the agent's own live task - see relevanceQuery for why the
24
- // task outranks the goal.
25
- const query = relevanceQuery(workingMemory, goal)
26
- const learned = selectEntries(store, learningPool, query, 5, { scope, key })
27
- const learnings = learned.entries
28
- const learningsRelevant = learned.relevant
29
- const decisions = selectEntries(store, decisionPool, query, 5, { scope, key }).entries
30
- const parts = ['## DSH memory context']
31
- if (goal) parts.push('### Goal\n' + goal)
32
- // Workspace state goes FIRST and is never buried: it is the overview, and its value
33
- // depends on the reader knowing how current it is. A stale section is shown WITH its age
34
- // rather than hidden, so it reads as something to check instead of something to trust.
35
- if (scope === 'workspace') {
36
- const state = readState(store.root, key)
37
- if (state && state.sections.length > 0) {
38
- const lines = state.sections.slice(0, 8).map((section) => {
39
- const marker = freshnessMarker(section.verdict, section.ageMs)
40
- const body = section.content.split(String.fromCharCode(10)).slice(0, 3).join(' ')
41
- return '- **' + section.name + '**' + marker + (body ? ': ' + body : '')
42
- })
43
- parts.push('### Repository state' + String.fromCharCode(10) + lines.join(String.fromCharCode(10)))
44
- }
45
- }
46
- // The agent must know this file exists, that it is THEIRS, and what it is for. So the card
47
- // names the file and its purpose, and an unrecorded file is called out rather than hidden:
48
- // silence would read as 'no working memory' when it actually means 'nothing written yet'.
49
- if (scope === 'workspace') {
50
- const relative = 'workspaces/' + key + '/working.md'
51
- const marker = workingMemory ? workingMarker(workingMemory.verdict, workingMemory.ageMs) : ''
52
- const hasContent = workingMemory && (workingMemory.sections.length > 0 || workingMemory.activity.length > 0)
53
- const lines = ['### Working memory — yours' + marker]
54
- lines.push('Your own file at ' + relative + ': the task in hand, recent activity, next steps.')
55
- lines.push('You own it — read and edit it freely; keep it current. Repository state lives separately in state.md.')
56
- if (hasContent) {
57
- for (const section of workingMemory.sections.slice(0, 4)) {
58
- lines.push('- **' + section.name + '**: ' + section.content.split(String.fromCharCode(10)).join(' ').slice(0, 300))
59
- }
60
- const recent = workingMemory.activity.slice(-4)
61
- if (recent.length) lines.push('- **Recently**: ' + recent.map((a) => a.text).join('; ').slice(0, 400))
62
- } else {
63
- lines.push('- (nothing recorded yet. Record your task with memory_working if it helps you — this is optional.)')
64
- }
65
- parts.push(lines.join(String.fromCharCode(10)))
66
- }
67
- // The heading says WHICH rule chose these. 'Recent' and 'relevant to this task' are different
68
- // claims, and a reader deciding how much to trust a line needs to know which one was made.
69
- if (learnings.length) {
70
- const heading = learningsRelevant ? '### Relevant learnings' : '### Recent learnings'
71
- parts.push(heading + '\n' + learnings.map((e) => '- ' + e.heading + ': ' + e.content).join('\n'))
72
- }
73
- // Settled decisions belong in front of the agent: they are the constraints it must respect.
74
- if (decisions.length) parts.push('### Decisions in force\n' + decisions.map((e) => '- ' + e.heading).join('\n'))
75
- // The codebase map is per workspace: show what has been mapped so far, so the agent
76
- // knows what it can already rely on and what is still unexplored.
77
- const mapped = scope === 'workspace' ? readLayerTopics(store.root, scope, key, 'codebase') : []
78
- if (mapped.length) {
79
- const shown = mapped.slice(0, 30).map((topic) => {
80
- const entry = topic.entries[0]
81
- return '- ' + (entry ? entry.heading : topic.slug)
82
- })
83
- if (mapped.length > 30) shown.push('- (+' + (mapped.length - 30) + ' more)')
84
- parts.push('### Codebase map\n' + shown.join('\n'))
85
- }
86
- // The footer is the one line the agent sees on every turn, so it carries the standing rule
87
- // about RESTRAINT, not just the tools. An agent that feels obliged to write every few
88
- // minutes turns memory into a junk drawer; declining is the correct outcome when nothing
89
- // durable was learned.
90
- // A subagent READS but does not write: memory is the main agent's account of the work, and the
91
- // parent already owns the delegation and its result. Telling a child to record would invite it
92
- // to write a partial account competing with its parent's, so the footer states the actual rule.
93
- const WRITE_FOOTER = [
94
- 'Use memory_search to recall more.',
95
- 'Write ONLY when something durable was actually learned: a decision, a constraint, a',
96
- 'preference, an environment fact, a lesson, or how a module works.',
97
- 'Writing NOTHING is a correct and expected outcome - most turns add nothing, and a fact that',
98
- 'only mattered for one turn is not memory. Never invent an entry to fill a gap.',
99
- ].join(String.fromCharCode(10))
100
- const READ_FOOTER = [
101
- 'Use memory_search to recall more.',
102
- 'Memory is written by the main agent that delegated this work.',
103
- 'Report what you find back to it, and do not record memory yourself.',
104
- ].join(String.fromCharCode(10))
105
- const footer = subagent ? READ_FOOTER : WRITE_FOOTER
106
- const body = parts.join('\n\n')
107
- return (body.length <= maxChars ? body : body.slice(0, maxChars)) + '\n\n' + footer
108
- }
1
+ // Session-start injection text: goal + working head + recent learnings + footer,
2
+ // char-capped and scope-aware.
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { layerDir } from './store.js'
6
+ import { buildLedger } from './ledger.js'
7
+ import { readLayerTopics } from './layers.js'
8
+ import { freshnessMarker, readState } from './state.js'
9
+ import { readWorking, workingMarker } from './working.js'
10
+ import { relevanceQuery, selectEntries } from './select.js'
11
+
12
+ export function buildInjectionText(store, { scope = 'global', key, goal, maxChars = 3600, subagent = false } = {}) {
13
+ const dir = layerDir(store.root, scope, key)
14
+ // Working memory is the AGENT's intent (task, recent activity, next steps); repository
15
+ const ledger = buildLedger(store.root)
16
+ const prefix = scope === 'global' ? 'global/' : 'workspaces/' + key + '/'
17
+ // The pools are LISTED here and CHOSEN later, because choosing needs the question, and the
18
+ // question comes from working memory - which is read below.
19
+ const learningPool = ledger.filter((e) => e.file.startsWith(prefix) && e.layer === 'learning')
20
+ const decisionPool = ledger.filter((e) => e.file.startsWith(prefix) && e.layer === 'decision')
21
+ // state (state.md) is the repository's. Different owners, different lifetimes: a repository
22
+ const workingMemory = scope === 'workspace' ? readWorking(store.root, scope, key) : undefined
23
+ // What this turn is about, from the agent's own live task - see relevanceQuery for why the
24
+ // task outranks the goal.
25
+ const query = relevanceQuery(workingMemory, goal)
26
+ const learned = selectEntries(store, learningPool, query, 5, { scope, key })
27
+ const learnings = learned.entries
28
+ const learningsRelevant = learned.relevant
29
+ const decisions = selectEntries(store, decisionPool, query, 5, { scope, key }).entries
30
+ const parts = ['## DSH memory context']
31
+ if (goal) parts.push('### Goal\n' + goal)
32
+ // Workspace state goes FIRST and is never buried: it is the overview, and its value
33
+ // depends on the reader knowing how current it is. A stale section is shown WITH its age
34
+ // rather than hidden, so it reads as something to check instead of something to trust.
35
+ if (scope === 'workspace') {
36
+ const state = readState(store.root, key)
37
+ if (state && state.sections.length > 0) {
38
+ const lines = state.sections.slice(0, 8).map((section) => {
39
+ const marker = freshnessMarker(section.verdict, section.ageMs)
40
+ const body = section.content.split(String.fromCharCode(10)).slice(0, 3).join(' ')
41
+ return '- **' + section.name + '**' + marker + (body ? ': ' + body : '')
42
+ })
43
+ parts.push('### Repository state' + String.fromCharCode(10) + lines.join(String.fromCharCode(10)))
44
+ }
45
+ }
46
+ // The agent must know this file exists, that it is THEIRS, and what it is for. So the card
47
+ // names the file and its purpose, and an unrecorded file is called out rather than hidden:
48
+ // silence would read as 'no working memory' when it actually means 'nothing written yet'.
49
+ if (scope === 'workspace') {
50
+ const relative = 'workspaces/' + key + '/working.md'
51
+ const marker = workingMemory ? workingMarker(workingMemory.verdict, workingMemory.ageMs) : ''
52
+ const hasContent = workingMemory && (workingMemory.sections.length > 0 || workingMemory.activity.length > 0)
53
+ const lines = ['### Working memory — yours' + marker]
54
+ lines.push('Your own file at ' + relative + ': the task in hand, recent activity, next steps.')
55
+ lines.push('You own it — read and edit it freely; keep it current. Repository state lives separately in state.md.')
56
+ if (hasContent) {
57
+ for (const section of workingMemory.sections.slice(0, 4)) {
58
+ lines.push('- **' + section.name + '**: ' + section.content.split(String.fromCharCode(10)).join(' ').slice(0, 300))
59
+ }
60
+ const recent = workingMemory.activity.slice(-4)
61
+ if (recent.length) lines.push('- **Recently**: ' + recent.map((a) => a.text).join('; ').slice(0, 400))
62
+ } else {
63
+ lines.push('- (nothing recorded yet. Record your task with memory_working if it helps you — this is optional.)')
64
+ }
65
+ parts.push(lines.join(String.fromCharCode(10)))
66
+ }
67
+ // The heading says WHICH rule chose these. 'Recent' and 'relevant to this task' are different
68
+ // claims, and a reader deciding how much to trust a line needs to know which one was made.
69
+ if (learnings.length) {
70
+ const heading = learningsRelevant ? '### Relevant learnings' : '### Recent learnings'
71
+ parts.push(heading + '\n' + learnings.map((e) => '- ' + e.heading + ': ' + e.content).join('\n'))
72
+ }
73
+ // Settled decisions belong in front of the agent: they are the constraints it must respect.
74
+ if (decisions.length) parts.push('### Decisions in force\n' + decisions.map((e) => '- ' + e.heading).join('\n'))
75
+ // The codebase map is per workspace: show what has been mapped so far, so the agent
76
+ // knows what it can already rely on and what is still unexplored.
77
+ const mapped = scope === 'workspace' ? readLayerTopics(store.root, scope, key, 'codebase') : []
78
+ if (mapped.length) {
79
+ const shown = mapped.slice(0, 30).map((topic) => {
80
+ const entry = topic.entries[0]
81
+ return '- ' + (entry ? entry.heading : topic.slug)
82
+ })
83
+ if (mapped.length > 30) shown.push('- (+' + (mapped.length - 30) + ' more)')
84
+ parts.push('### Codebase map\n' + shown.join('\n'))
85
+ }
86
+ // The footer is the one line the agent sees on every turn, so it carries the standing rule
87
+ // about RESTRAINT, not just the tools. An agent that feels obliged to write every few
88
+ // minutes turns memory into a junk drawer; declining is the correct outcome when nothing
89
+ // durable was learned.
90
+ // A subagent READS but does not write: memory is the main agent's account of the work, and the
91
+ // parent already owns the delegation and its result. Telling a child to record would invite it
92
+ // to write a partial account competing with its parent's, so the footer states the actual rule.
93
+ const WRITE_FOOTER = [
94
+ 'Use memory_search to recall more.',
95
+ 'Write ONLY when something durable was actually learned: a decision, a constraint, a',
96
+ 'preference, an environment fact, a lesson, or how a module works.',
97
+ 'Writing NOTHING is a correct and expected outcome - most turns add nothing, and a fact that',
98
+ 'only mattered for one turn is not memory. Never invent an entry to fill a gap.',
99
+ ].join(String.fromCharCode(10))
100
+ const READ_FOOTER = [
101
+ 'Use memory_search to recall more.',
102
+ 'Memory is written by the main agent that delegated this work.',
103
+ 'Report what you find back to it, and do not record memory yourself.',
104
+ ].join(String.fromCharCode(10))
105
+ const footer = subagent ? READ_FOOTER : WRITE_FOOTER
106
+ const body = parts.join('\n\n')
107
+ return (body.length <= maxChars ? body : body.slice(0, maxChars)) + '\n\n' + footer
108
+ }