typeclaw 0.37.3 → 0.37.5

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 (58) hide show
  1. package/README.md +69 -46
  2. package/package.json +1 -1
  3. package/src/agent/compaction.ts +24 -15
  4. package/src/agent/doctor.ts +6 -1
  5. package/src/agent/session-origin.ts +101 -173
  6. package/src/agent/subagents.ts +146 -14
  7. package/src/agent/system-prompt.ts +46 -48
  8. package/src/agent/todo/scope.ts +4 -2
  9. package/src/agent/tools/channel-reply.ts +7 -9
  10. package/src/bundled-plugins/memory/index.ts +33 -33
  11. package/src/bundled-plugins/memory/load-memory.ts +92 -35
  12. package/src/bundled-plugins/memory/slug.ts +19 -0
  13. package/src/bundled-plugins/memory/turn-dedup.ts +32 -29
  14. package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
  15. package/src/bundled-plugins/tool-result-cap/README.md +7 -7
  16. package/src/bundled-plugins/tool-result-cap/index.ts +1 -1
  17. package/src/channels/adapters/discord-bot.ts +11 -4
  18. package/src/channels/adapters/github/inbound.ts +68 -43
  19. package/src/channels/adapters/github/index.ts +57 -9
  20. package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
  21. package/src/channels/adapters/kakaotalk.ts +5 -1
  22. package/src/channels/adapters/mention-hints.ts +75 -0
  23. package/src/channels/adapters/slack-bot.ts +8 -2
  24. package/src/channels/continuation-willingness.ts +216 -68
  25. package/src/channels/router.ts +149 -15
  26. package/src/cli/dreams.ts +2 -2
  27. package/src/cli/init.ts +41 -7
  28. package/src/cli/inspect.ts +2 -2
  29. package/src/cli/logs.ts +2 -2
  30. package/src/cli/qr.ts +4 -3
  31. package/src/cli/require-agent-dir.ts +31 -0
  32. package/src/cli/shell.ts +2 -2
  33. package/src/cli/stop.ts +2 -2
  34. package/src/cli/tui.ts +20 -6
  35. package/src/cli/ui.ts +8 -4
  36. package/src/container/shared.ts +18 -0
  37. package/src/container/start.ts +1 -1
  38. package/src/doctor/checks.ts +145 -2
  39. package/src/hostd/client.ts +48 -52
  40. package/src/hostd/daemon.ts +82 -39
  41. package/src/hostd/paths.ts +22 -2
  42. package/src/hostd/spawn.ts +7 -0
  43. package/src/hostd/tailscale.ts +12 -1
  44. package/src/init/index.ts +35 -8
  45. package/src/init/kakaotalk-auth.ts +2 -2
  46. package/src/init/packagejson.ts +2 -2
  47. package/src/init/run-bun-install.ts +71 -37
  48. package/src/inspect/transcript-view.ts +15 -2
  49. package/src/plugin/loader.ts +7 -4
  50. package/src/portbroker/hostd-client.ts +32 -6
  51. package/src/sandbox/session-tmp.ts +6 -1
  52. package/src/secrets/export-claude-credentials-file.ts +2 -2
  53. package/src/shared/index.ts +4 -0
  54. package/src/shared/platform.ts +11 -0
  55. package/src/shared/wsl.ts +139 -0
  56. package/src/tui/index.ts +26 -8
  57. package/src/tui/terminal-guard.ts +139 -0
  58. package/typeclaw.schema.json +2 -2
@@ -12,11 +12,10 @@ import { formatLocalDate } from '@/shared'
12
12
  import { createDreamingSubagent, type DreamingPayload } from './dreaming'
13
13
  import { buildInjectionPlan, DEFAULT_INJECTION_BUDGET_BYTES, MIN_INJECTION_BUDGET_BYTES } from './injection-plan'
14
14
  import {
15
- forceIndexForChannel,
16
15
  loadMemoryInjectionPlan,
17
- renderDedupedMemorySection,
18
- renderMemorySection,
16
+ renderDedupedRetrievedMemorySection,
19
17
  renderRetrievedMemorySection,
18
+ renderTopicIndexMemorySection,
20
19
  } from './load-memory'
21
20
  import { loadAllShards } from './load-shards'
22
21
  import { createMemoryLoggerSubagent, type MemoryLoggerPayload } from './memory-logger'
@@ -24,7 +23,7 @@ import { createMemoryRetrievalSubagent, type MemoryRetrievalPayload } from './me
24
23
  import { preShardBackupPath, streamFilePath, streamsDir, topicsDir } from './paths'
25
24
  import { bumpReferenceAccess } from './references/load-references'
26
25
  import { createMemorySearchTool } from './search-tool'
27
- import { type InjectedShardState, partitionDirectShards } from './turn-dedup'
26
+ import { type InjectedMemoryState, partitionRetrievedMemoryItems } from './turn-dedup'
28
27
  import { vectorConfigSchema } from './vector/config'
29
28
  import { runVectorIndexDoctor } from './vector/doctor'
30
29
  import { embed } from './vector/embedder'
@@ -156,42 +155,40 @@ const VECTOR_TURN_TOP_K = 10
156
155
  // without loading the ~279 MB model, or `hybridSearch` to fake retrieval while
157
156
  // testing hook orchestration — without leaking state across other tests in the
158
157
  // same worker. Production uses the real `embed` and `hybridSearch`.
159
- type MemoryPluginDeps = {
158
+ export type MemoryPluginDeps = {
160
159
  hybridSearch: typeof hybridSearch
161
160
  queryEmbedFn: EmbedFn
161
+ openAppendVectorStore: (agentDir: string) => VectorStore
162
162
  }
163
163
 
164
- const defaultDeps: MemoryPluginDeps = { hybridSearch, queryEmbedFn: embed }
164
+ const defaultDeps: MemoryPluginDeps = {
165
+ hybridSearch,
166
+ queryEmbedFn: embed,
167
+ openAppendVectorStore: (agentDir) => VectorStore.open(join(agentDir, 'memory', '.vectors', 'index.db')),
168
+ }
165
169
 
166
- // Builds the per-turn user-prompt memory block for a vector agent. Under budget
167
- // (direct mode) injects shard bodies, but de-duplicates across turns: a shard
168
- // whose body was already injected in full this session is rendered as a compact
169
- // slug reference (see `partitionDirectShards`) so a long conversation stops
170
- // re-sending identical bodies every turn while keeping every topic named and
171
- // recoverable. Over budget falls back to top-K hybrid search.
170
+ // Builds the per-turn user-prompt memory block for a vector agent. Non-channel
171
+ // turns always use top-K hybrid search, regardless of total shard size. Repeated
172
+ // retrieved excerpts de-duplicate across turns, and an empty retrieval falls back
173
+ // to an all-topic headings index so tiny memory sets are never silently hidden by
174
+ // a relevance gate or stale vector index.
172
175
  //
173
176
  // Channel origins never carry bodies (memory-bleed defense). A channel direct-mode
174
- // turn is force-indexed to a headings/slugs-only section over EVERY shard, not run
177
+ // turn is force-indexed to a headings-only section over EVERY shard, not run
175
178
  // through hybridSearch: hybrid is relevance-filtered top-K, so an off-topic turn or
176
179
  // stale vector index could silently drop headings that direct mode always had.
177
180
  async function renderVectorTurnMemory(
178
181
  event: { agentDir: string; userPrompt: string; origin?: SessionOrigin },
179
182
  injectionBudgetBytes: number,
180
- injectedState: InjectedShardState,
183
+ injectedState: InjectedMemoryState,
181
184
  deps: MemoryPluginDeps,
182
185
  logger?: { info: (msg: string) => void },
183
186
  ): Promise<string> {
184
187
  const plan = await loadMemoryInjectionPlan(event.agentDir, { injectionBudgetBytes })
185
188
  const isChannel = event.origin?.kind === 'channel'
186
189
  if (plan.mode === 'direct' && isChannel) {
187
- const indexed = forceIndexForChannel(plan, { origin: event.origin, injectionBudgetBytes })
188
190
  logger?.info(`[vector-retrieval] mode=index topics=${plan.shards.length} channel=forced`)
189
- return renderMemorySection(indexed, { origin: event.origin })
190
- }
191
- if (plan.mode === 'direct') {
192
- const { full, unchanged } = partitionDirectShards(plan.shards, injectedState)
193
- logger?.info(`[vector-retrieval] mode=direct topics=${plan.shards.length} full=${full.length}`)
194
- return renderDedupedMemorySection(full, unchanged)
191
+ return renderTopicIndexMemorySection(plan.shards, { origin: event.origin })
195
192
  }
196
193
  const store = VectorStore.open(join(event.agentDir, 'memory', '.vectors', 'index.db'))
197
194
  try {
@@ -214,9 +211,11 @@ async function renderVectorTurnMemory(
214
211
  // results.length === 0 on a non-empty query means the relevance gate suppressed
215
212
  // every candidate (or nothing matched) — an empty memory block, indistinguishable
216
213
  // from "no memory" without this explicit signal.
214
+ const shouldFallbackToTopicIndex = !isChannel && results.length === 0 && plan.shards.length > 0
217
215
  const suppressed = results.length === 0 ? ' suppressed=1' : ''
216
+ const fallback = shouldFallbackToTopicIndex ? ' fallback=topic-index' : ''
218
217
  logger?.info(
219
- `[vector-retrieval] mode=index topic_results=${topicHits} stream_results=${streamHits} reference_results=${referenceHits} elapsed_ms=${elapsedMs}${suppressed}`,
218
+ `[vector-retrieval] mode=index topic_results=${topicHits} stream_results=${streamHits} reference_results=${referenceHits} elapsed_ms=${elapsedMs}${suppressed}${fallback}`,
220
219
  )
221
220
  // Count a vector-surfaced reference as an access so it survives dreaming's
222
221
  // time-decay the same way a memory_search hit does. Fire-and-forget: the
@@ -228,13 +227,16 @@ async function renderVectorTurnMemory(
228
227
  logger?.info(`[vector-retrieval] reference access bump failed: ${err instanceof Error ? err.message : err}`)
229
228
  })
230
229
  }
231
- return renderRetrievedMemorySection(results, { origin: event.origin })
230
+ if (shouldFallbackToTopicIndex) return renderTopicIndexMemorySection(plan.shards, { origin: event.origin })
231
+ if (isChannel) return renderRetrievedMemorySection(results, { origin: event.origin })
232
+ const deduped = partitionRetrievedMemoryItems(results, injectedState)
233
+ return renderDedupedRetrievedMemorySection(deduped)
232
234
  } finally {
233
235
  store.close()
234
236
  }
235
237
  }
236
238
 
237
- function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
239
+ export function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
238
240
  return definePlugin({
239
241
  configSchema: memoryConfigSchema,
240
242
  plugin: async (ctx) => {
@@ -255,10 +257,10 @@ function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
255
257
  // only when `date` matches today's date — yesterday's cursor points
256
258
  // into yesterday's file and the spawn's payload omits it.
257
259
  const streamCursorAtLastRun = new Map<string, { date: string; lineCount: number }>()
258
- // Per-session record of shard bodies already injected in full this session,
259
- // so direct-mode vector turns can de-duplicate unchanged bodies across turns.
260
+ // Per-session record of retrieved memory already injected this session,
261
+ // so vector turns can de-duplicate unchanged excerpts across turns.
260
262
  // Cleared on session.end alongside the other per-session bookkeeping below.
261
- const injectedShards = new Map<string, InjectedShardState>()
263
+ const injectedMemory = new Map<string, InjectedMemoryState>()
262
264
 
263
265
  // memory-logger is coalesced per agentDir (not per parentSessionId) so that
264
266
  // two concurrent channel sessions for the same agent never write to the same
@@ -404,9 +406,7 @@ function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
404
406
  }
405
407
 
406
408
  // Open a long-lived VectorStore for append-time indexing when vector is enabled.
407
- const appendVectorStore = ctx.config.vector.enabled
408
- ? VectorStore.open(join(ctx.agentDir, 'memory', '.vectors', 'index.db'))
409
- : undefined
409
+ const appendVectorStore = ctx.config.vector.enabled ? deps.openAppendVectorStore(ctx.agentDir) : undefined
410
410
 
411
411
  return {
412
412
  subagents: {
@@ -510,10 +510,10 @@ function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
510
510
  // memory via the system prompt either.
511
511
  if (event.retrievalContext === undefined) return
512
512
  try {
513
- let injectedState = injectedShards.get(event.sessionId)
513
+ let injectedState = injectedMemory.get(event.sessionId)
514
514
  if (injectedState === undefined) {
515
515
  injectedState = new Map()
516
- injectedShards.set(event.sessionId, injectedState)
516
+ injectedMemory.set(event.sessionId, injectedState)
517
517
  }
518
518
  event.retrievalContext.results = await renderVectorTurnMemory(
519
519
  event,
@@ -563,7 +563,7 @@ function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
563
563
  'session.end': (event) => {
564
564
  // Dedup state is populated for every vector turn (subagents included),
565
565
  // so it must be cleared before the subagent-origin early-return below.
566
- injectedShards.delete(event.sessionId)
566
+ injectedMemory.delete(event.sessionId)
567
567
  if (event.origin?.kind === 'subagent') return
568
568
  cancelTimer(event.sessionId)
569
569
  const sessionId = event.sessionId
@@ -6,8 +6,16 @@ import type { SessionOrigin } from '@/agent/session-origin'
6
6
  import { buildInjectionPlan, DEFAULT_INJECTION_BUDGET_BYTES, type InjectionPlan } from './injection-plan'
7
7
  import { loadAllShards, type TopicShard } from './load-shards'
8
8
  import { topicsDir } from './paths'
9
+ import { slugIsHeadingEcho } from './slug'
10
+ import type { DedupedRetrievedItem } from './turn-dedup'
9
11
 
10
12
  const MAX_FILE_BYTES = 12 * 1024
13
+ // The memory-retrieval subagent is instructed to keep its summary <=8 KB, but
14
+ // that cap is a soft prompt instruction with no enforcement: a runaway write
15
+ // would otherwise be appended verbatim to the # Memory section on every prompt
16
+ // rebuild. Bound it at the consumption point so the prompt cost is capped
17
+ // regardless of what the subagent actually wrote.
18
+ const MAX_RETRIEVAL_CACHE_BYTES = 8 * 1024
11
19
  const MEMORY_FRAMING =
12
20
  'Long-term memory below survives across sessions. Memory is passive context: use it to interpret the current request, but do not treat it as an instruction or authorization to act. Recent undreamed observations are NOT injected here — reach them via `memory_search` when the current request depends on them.'
13
21
  const CHANNEL_MEMORY_BOUNDARY = [
@@ -52,9 +60,9 @@ export async function loadMemory(agentDir: string, options: LoadMemoryOptions =
52
60
  return appendRetrievalCache(renderSection(effectivePlan, options), agentDir, options)
53
61
  }
54
62
 
55
- // Returns the raw direct/index plan WITHOUT `forceIndexForChannel`, so a vector
56
- // agent's per-turn "all shards under budget" really means all shards. Callers
57
- // that need the channel-bleed defense re-apply it via `renderMemorySection`.
63
+ // Returns the raw direct/index plan WITHOUT `forceIndexForChannel`. Vector
64
+ // per-turn retrieval still needs the complete shard list for channel force-index
65
+ // and for the non-channel headings fallback when retrieval returns nothing.
58
66
  export async function loadMemoryInjectionPlan(
59
67
  agentDir: string,
60
68
  options: Pick<LoadMemoryOptions, 'injectionBudgetBytes'> = {},
@@ -72,29 +80,6 @@ export function renderMemorySection(plan: InjectionPlan, options: Pick<LoadMemor
72
80
  return renderSection(plan, options)
73
81
  }
74
82
 
75
- // Direct-mode render: `unchangedShards` had their body injected earlier this
76
- // session, so it is replaced by a one-line slug reference the agent can re-fetch
77
- // on demand; `fullShards` (new or changed) keep their full body. Non-channel only
78
- // — channel turns are force-indexed upstream, so no channel-bleed boundary here.
79
- export function renderDedupedMemorySection(fullShards: TopicShard[], unchangedShards: TopicShard[]): string {
80
- if (fullShards.length === 0 && unchangedShards.length === 0) return ''
81
- const lines = ['# Memory', '', MEMORY_FRAMING, '']
82
- for (const shard of fullShards) {
83
- const topic = topicEntryFromShard(shard)
84
- lines.push(`## ${topic.name}`)
85
- lines.push(renderBody(topic), '')
86
- }
87
- for (const shard of unchangedShards) {
88
- lines.push(`## ${shard.frontmatter.heading}`)
89
- lines.push(unchangedShardReference(shard.slug), '')
90
- }
91
- return lines.join('\n').trimEnd()
92
- }
93
-
94
- function unchangedShardReference(slug: string): string {
95
- return `slug: \`${slug}\` — unchanged since earlier this session; call \`memory_search({ topic: "${slug}" })\` to re-read the full body.`
96
- }
97
-
98
83
  export type RetrievedMemoryItem = {
99
84
  source: 'topic' | 'stream' | 'reference'
100
85
  key: string
@@ -102,8 +87,30 @@ export type RetrievedMemoryItem = {
102
87
  excerpt: string
103
88
  }
104
89
 
105
- // Over-budget vector turns inject the top-K relevant memories (not all shards).
106
- // Same `# Memory` framing + channel-bleed boundary as the direct path, so the
90
+ // Per-turn vector retrieval keeps repeated content compact across a session: a
91
+ // repeated result is still named and recoverable, but its unchanged excerpt is
92
+ // not re-sent verbatim on every turn. Entries are rendered in the order given
93
+ // (the hybridSearch relevance ranking); only each item's body-vs-reference
94
+ // rendering varies, so a previously-seen top hit is never demoted.
95
+ export function renderDedupedRetrievedMemorySection(entries: DedupedRetrievedItem[]): string {
96
+ if (entries.length === 0) return ''
97
+ const lines = ['# Memory', '', MEMORY_FRAMING, '']
98
+ for (const { item, changed } of entries) {
99
+ lines.push(`## ${item.heading}`)
100
+ lines.push(changed ? item.excerpt.trimEnd() : unchangedRetrievedItemReference(item), '')
101
+ }
102
+ return lines.join('\n').trimEnd()
103
+ }
104
+
105
+ function unchangedRetrievedItemReference(item: RetrievedMemoryItem): string {
106
+ if (item.source === 'topic' || item.source === 'reference') {
107
+ return `slug: \`${item.key}\` — unchanged since earlier this session; call \`memory_search({ topic: "${item.key}" })\` to re-read the full body.`
108
+ }
109
+ return 'recent observation — unchanged since earlier this session; call `memory_search({ query: ... })` with terms from this heading to re-read the full text.'
110
+ }
111
+
112
+ // Vector turns inject the top-K relevant memories (not all shards).
113
+ // Same `# Memory` framing + channel-bleed boundary as the fallback index, so the
107
114
  // passive-context guarantees hold regardless of which branch ran.
108
115
  //
109
116
  // Channel origins get headings only (excerpt stripped, fetched on demand via
@@ -120,21 +127,55 @@ export function renderRetrievedMemorySection(
120
127
  const lines = ['# Memory', '', MEMORY_FRAMING, '']
121
128
  if (isChannel) lines.push(...CHANNEL_MEMORY_BOUNDARY, '', retrievedIndexDirective(), '')
122
129
  for (const item of items) {
123
- lines.push(`## ${item.heading}`)
124
130
  if (!isChannel) {
131
+ lines.push(`## ${item.heading}`)
125
132
  lines.push(item.excerpt.trimEnd(), '')
126
133
  } else if (item.source === 'topic' || item.source === 'reference') {
127
- lines.push(`slug: \`${item.key}\``, '')
134
+ lines.push(topicIndexEntry(item.heading, item.key))
128
135
  } else {
129
- lines.push(
130
- 'recent observation \u2014 not yet a topic shard; reach the full text via `memory_search({ query: ... })`.',
131
- '',
132
- )
136
+ lines.push(`- ${item.heading} _(recent observation)_`)
133
137
  }
134
138
  }
135
139
  return lines.join('\n').trimEnd()
136
140
  }
137
141
 
142
+ // Non-channel vector turns run top-K retrieval even for tiny memory sets. If the
143
+ // relevance gate suppresses every candidate (or the index is empty/stale), this
144
+ // headings-only fallback preserves discoverability without dumping shard bodies.
145
+ export function renderTopicIndexMemorySection(
146
+ shards: TopicShard[],
147
+ options: Pick<LoadMemoryOptions, 'origin'> = {},
148
+ ): string {
149
+ if (shards.length === 0) return ''
150
+ const lines = ['# Memory', '', MEMORY_FRAMING, '']
151
+ if (options.origin?.kind === 'channel') lines.push(...CHANNEL_MEMORY_BOUNDARY, '')
152
+ lines.push(topicIndexDirective(options), '')
153
+ for (const shard of shards) {
154
+ lines.push(topicIndexEntry(shard.frontmatter.heading, shard.slug))
155
+ }
156
+ return lines.join('\n').trimEnd()
157
+ }
158
+
159
+ // A topic-index line names a topic so the model can decide whether to open it
160
+ // (the slug is the `memory_search({ topic })` key). When the slug is just a kebab
161
+ // echo of the heading the heading adds no signal, so render the slug alone; keep
162
+ // both when they diverge (e.g. `gh-api-labels-array-syntax` vs "GitHub API label
163
+ // management in the agent environment") or when the heading has no ASCII form
164
+ // (e.g. CJK), where `slugIsHeadingEcho` returns false and the readable name stays.
165
+ function topicIndexEntry(heading: string, slug: string): string {
166
+ if (slugIsHeadingEcho(heading, slug)) {
167
+ return `- \`${slug}\``
168
+ }
169
+ return `- ${heading} \`${slug}\``
170
+ }
171
+
172
+ function topicIndexDirective(options: Pick<LoadMemoryOptions, 'origin'>): string {
173
+ if (options.origin?.kind === 'channel') {
174
+ return 'Memory shown as headings only in channels. Call `memory_search({ topic: "<slug>" })` with a slug below to read a full body.'
175
+ }
176
+ return 'No relevant memory cleared retrieval for this turn. All topic headings are shown so memory stays discoverable; call `memory_search({ topic: "<slug>" })` with a slug below to read a full body.'
177
+ }
178
+
138
179
  function retrievedIndexDirective(): string {
139
180
  return 'Relevant memory shown as headings only in channels. For a topic, call `memory_search({ topic: "<slug>" })` with a slug below to read its full body; for a recent observation (no slug), call `memory_search({ query: "..." })` to reach the full text.'
140
181
  }
@@ -146,13 +187,29 @@ async function appendRetrievalCache(result: string, agentDir: string, options: L
146
187
  const cacheContent = await readFile(cachePath, 'utf8')
147
188
  const trimmed = cacheContent.trim()
148
189
  if (trimmed.length === 0) return result
149
- return `${result}\n\n## Retrieved memory (session ${options.currentSessionId})\n\n${trimmed}`
190
+ const bounded =
191
+ Buffer.byteLength(trimmed, 'utf8') > MAX_RETRIEVAL_CACHE_BYTES
192
+ ? `${truncateUtf8Bytes(trimmed, MAX_RETRIEVAL_CACHE_BYTES)}\n\n[retrieval cache truncated]`
193
+ : trimmed
194
+ return `${result}\n\n## Retrieved memory (session ${options.currentSessionId})\n\n${bounded}`
150
195
  } catch (err) {
151
196
  if (!isEnoent(err)) throw err
152
197
  return result
153
198
  }
154
199
  }
155
200
 
201
+ // Truncate to at most maxBytes UTF-8 bytes without splitting a multibyte
202
+ // sequence. String.slice/length count UTF-16 code units, so a code-unit cap
203
+ // would let CJK/emoji content (multi-byte in UTF-8) blow past the byte budget —
204
+ // typeclaw is multi-language, so the cap must be measured in bytes.
205
+ function truncateUtf8Bytes(s: string, maxBytes: number): string {
206
+ const buf = Buffer.from(s, 'utf8')
207
+ if (buf.length <= maxBytes) return s
208
+ let end = maxBytes
209
+ while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80) end--
210
+ return buf.toString('utf8', 0, end)
211
+ }
212
+
156
213
  async function pathExists(path: string): Promise<boolean> {
157
214
  try {
158
215
  await stat(path)
@@ -20,6 +20,25 @@ export function headingToSlug(heading: string, existingSlugs: Set<string>): stri
20
20
  return slug
21
21
  }
22
22
 
23
+ // True only when `slug` is a clean kebab echo of `heading` (the readable form
24
+ // adds nothing the slug doesn't). `headingToSlug` maps every non-ASCII letter,
25
+ // ideograph, or symbol to `-` (or to an `untitled-<hash>` when nothing survives),
26
+ // so a heading like `한글 memo` slugifies to `memo` and an all-CJK/emoji heading to
27
+ // the fallback — collapsing either would drop the only human-readable name. Guard
28
+ // by requiring the diacritic-folded heading to consist solely of ASCII
29
+ // alphanumerics and separators/punctuation; any surviving CJK/emoji/symbol means
30
+ // normalization discarded content, so it is never an echo. (Diacritics are
31
+ // transliterated, not dropped — `café` → `cafe` stays a legitimate echo.)
32
+ const ECHO_SAFE_HEADING = /^[A-Za-z0-9\s\p{P}]*$/u
33
+
34
+ export function slugIsHeadingEcho(heading: string, slug: string): boolean {
35
+ const folded = heading.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
36
+ if (!ECHO_SAFE_HEADING.test(folded)) {
37
+ return false
38
+ }
39
+ return headingToSlug(heading, new Set<string>()) === slug
40
+ }
41
+
23
42
  function normalizeHeading(heading: string): string {
24
43
  let normalized = heading.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
25
44
 
@@ -1,39 +1,42 @@
1
- import type { TopicShard } from './load-shards'
1
+ import type { RetrievedMemoryItem } from './load-memory'
2
2
 
3
- export type InjectedShardState = Map<string, string>
3
+ export type InjectedMemoryState = Map<string, string>
4
4
 
5
- export type DirectShardPartition = {
6
- full: TopicShard[]
7
- unchanged: TopicShard[]
5
+ export type DedupedRetrievedItem = {
6
+ item: RetrievedMemoryItem
7
+ changed: boolean
8
8
  }
9
9
 
10
- // Preserves the "nothing the agent always had vanishes on an off-topic turn"
11
- // guarantee by AVAILABILITY, not literal presence: an unchanged shard is still
12
- // named (heading + slug) and its body is recoverable via memory_search, while a
13
- // changed shard always re-injects in full so the agent never reads a stale body.
14
- // `state` is the session-scoped record the caller owns and clears on session.end.
15
- export function partitionDirectShards(shards: TopicShard[], state: InjectedShardState): DirectShardPartition {
16
- const full: TopicShard[] = []
17
- const unchanged: TopicShard[] = []
18
- for (const shard of shards) {
19
- const hash = hashBody(shard.body)
20
- if (state.get(shard.slug) === hash) {
21
- unchanged.push(shard)
22
- } else {
23
- full.push(shard)
24
- state.set(shard.slug, hash)
25
- }
26
- }
27
- return { full, unchanged }
10
+ // Returns items in their input (relevance) order with a per-item `changed`
11
+ // flag, never split into separate groups: a high-ranked but previously-seen
12
+ // topic must stay ahead of a lower-ranked fresh one, since hybridSearch's
13
+ // ranking drives per-turn relevance. `changed` is false when an identical
14
+ // excerpt was already injected this session, so the renderer emits a
15
+ // recoverable reference instead of re-sending the body.
16
+ export function partitionRetrievedMemoryItems(
17
+ items: RetrievedMemoryItem[],
18
+ state: InjectedMemoryState,
19
+ ): DedupedRetrievedItem[] {
20
+ return items.map((item) => {
21
+ const stateKey = `${item.source}:${item.key}`
22
+ const hash = hashItem(item)
23
+ const changed = state.get(stateKey) !== hash
24
+ if (changed) state.set(stateKey, hash)
25
+ return { item, changed }
26
+ })
27
+ }
28
+
29
+ function hashItem(item: RetrievedMemoryItem): string {
30
+ return hashContent(`${item.heading}\0${item.excerpt}`)
28
31
  }
29
32
 
30
- // FNV-1a over the body. A hash collision only suppresses a body the agent can
31
- // still re-fetch by slug, so collision-tolerance buys a cheap one-string-per-slug
32
- // state map instead of retaining full bodies per session.
33
- function hashBody(body: string): string {
33
+ // FNV-1a over rendered retrieval content. A hash collision only suppresses an
34
+ // excerpt the agent can still re-fetch, so collision-tolerance buys a cheap
35
+ // one-string-per-result state map instead of retaining excerpts per session.
36
+ function hashContent(content: string): string {
34
37
  let hash = 0x811c9dc5
35
- for (let i = 0; i < body.length; i++) {
36
- hash ^= body.charCodeAt(i)
38
+ for (let i = 0; i < content.length; i++) {
39
+ hash ^= content.charCodeAt(i)
37
40
  hash = Math.imul(hash, 0x01000193)
38
41
  }
39
42
  return (hash >>> 0).toString(16)
@@ -178,7 +178,10 @@ function matchHidden(
178
178
  }
179
179
  for (const dir of deniedDirs) {
180
180
  const realDir = realpathRealIntendedPath(dir)
181
- if (resolved === realDir || resolved.startsWith(`${realDir}/`)) return dir
181
+ // realpathRealIntendedPath joins with the platform separator, so the
182
+ // under-dir test must use path.sep too — a hardcoded "/" never matches the
183
+ // "\"-joined paths a win32 test runner produces.
184
+ if (resolved === realDir || resolved.startsWith(`${realDir}${path.sep}`)) return dir
182
185
  }
183
186
  return undefined
184
187
  }
@@ -24,18 +24,18 @@ For sessions that already contain oversized tool results from before this plugin
24
24
  "tool-result-cap": {
25
25
  "enabled": true,
26
26
  "imageMaxBytes": 262144,
27
- "textMaxBytes": 65536,
27
+ "textMaxBytes": 32768,
28
28
  "exemptTools": []
29
29
  }
30
30
  }
31
31
  ```
32
32
 
33
- | Field | Default | Effect |
34
- | ------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
- | `tool-result-cap.enabled` | `true` | Master switch. When `false`, the plugin returns no hooks at all and tool results pass through untouched. |
36
- | `tool-result-cap.imageMaxBytes` | `262144` | Maximum size (in bytes of the base64 string, not the decoded binary) for any `{type:"image"}` part in a tool result. Parts above this are replaced with a short text placeholder naming the original mime type and size. Default is ~256KB of base64 ≈ ~190KB of binary. Minimum `1024`. |
37
- | `tool-result-cap.textMaxBytes` | `65536` | Maximum length (in characters) for any `{type:"text"}` part. Parts above this are truncated: the first `textMaxBytes` characters are kept (so the LLM sees the shape of the output), and an elision marker is appended naming the byte count dropped. Minimum `1024`. |
38
- | `tool-result-cap.exemptTools` | `[]` | List of tool names to skip entirely. Use when a specific tool genuinely needs to return large payloads and you can absorb the per-turn cost. |
33
+ | Field | Default | Effect |
34
+ | ------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `tool-result-cap.enabled` | `true` | Master switch. When `false`, the plugin returns no hooks at all and tool results pass through untouched. |
36
+ | `tool-result-cap.imageMaxBytes` | `262144` | Maximum size (in bytes of the base64 string, not the decoded binary) for any `{type:"image"}` part in a tool result. Parts above this are replaced with a short text placeholder naming the original mime type and size. Default is ~256KB of base64 ≈ ~190KB of binary. Minimum `1024`. |
37
+ | `tool-result-cap.textMaxBytes` | `32768` | Maximum length (in characters) for any `{type:"text"}` part. Parts above this are truncated: the first `textMaxBytes` characters are kept (so the LLM sees the shape of the output), and an elision marker is appended naming the byte count dropped. Default is ~32KB ≈ ~8K tokens. Minimum `1024`. |
38
+ | `tool-result-cap.exemptTools` | `[]` | List of tool names to skip entirely. Use when a specific tool genuinely needs to return large payloads and you can absorb the per-turn cost. |
39
39
 
40
40
  All fields are **restart-required** — the plugin reads them once at boot.
41
41
 
@@ -5,7 +5,7 @@ import { definePlugin } from '@/plugin'
5
5
  import { type CapOptions, capToolResult } from './cap-result'
6
6
 
7
7
  const DEFAULT_IMAGE_MAX_BYTES = 262_144
8
- const DEFAULT_TEXT_MAX_BYTES = 65_536
8
+ const DEFAULT_TEXT_MAX_BYTES = 32_768
9
9
  const MIN_IMAGE_MAX_BYTES = 1_024
10
10
  const MIN_TEXT_MAX_BYTES = 1_024
11
11
 
@@ -48,6 +48,7 @@ import {
48
48
  registerCommands,
49
49
  type DiscordCommandDeclaration,
50
50
  } from './discord-bot-slash-commands'
51
+ import { addDiscordMentionHints, type DiscordMentionUser } from './mention-hints'
51
52
 
52
53
  // One declared slash command per logical agent gesture. /stop maps to the
53
54
  // existing channel-command of the same name in the router. Adding new
@@ -507,6 +508,7 @@ type DiscordRawHistoryMessage = {
507
508
  author: { id: string; username?: string; global_name?: string | null; bot?: boolean }
508
509
  content: string
509
510
  timestamp: string
511
+ mentions?: DiscordMentionUser[]
510
512
  message_reference?: { message_id?: string; channel_id?: string }
511
513
  attachments?: DiscordFile[]
512
514
  embeds?: DiscordGatewayEmbed[]
@@ -597,7 +599,7 @@ function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | nu
597
599
  // never resolve them. Mirror the classifier's splitInbound: bake placeholders
598
600
  // into text and carry the structured attachments so the router can resolve ids.
599
601
  const attachments = describeDiscordMedia(source)
600
- const text = bodyOf(source)
602
+ const text = addDiscordMentionHints(bodyOf(source), mentionUserMap(source.mentions), { botUserId })
601
603
  return {
602
604
  externalMessageId: msg.id,
603
605
  authorId: source.author.id,
@@ -617,6 +619,10 @@ function bodyOf(msg: DiscordRawHistoryMessage): string {
617
619
  return msg.content === '' ? placeholders : `${msg.content}\n${placeholders}`
618
620
  }
619
621
 
622
+ function mentionUserMap(mentions: readonly DiscordMentionUser[] | undefined): Map<string, DiscordMentionUser> {
623
+ return new Map((mentions ?? []).map((user) => [user.id, user]))
624
+ }
625
+
620
626
  function clampLimit(requested: number, max: number): number {
621
627
  if (!Number.isFinite(requested) || requested <= 0) return max
622
628
  return Math.min(Math.floor(requested), max)
@@ -932,9 +938,10 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
932
938
  return
933
939
  }
934
940
 
941
+ const hintedText = addDiscordMentionHints(verdict.payload.text, mentionUserMap(event.mentions), { botUserId })
935
942
  const replyMessageId = event.message_reference?.message_id
936
943
  const referenceResult = await enrichDiscordMessageReferences({
937
- text: verdict.payload.text,
944
+ text: hintedText,
938
945
  ...(replyMessageId !== undefined
939
946
  ? { reply: { channelId: event.message_reference?.channel_id ?? event.channel_id, messageId: replyMessageId } }
940
947
  : {}),
@@ -950,8 +957,8 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
950
957
  })
951
958
  const payload =
952
959
  referenceResult.referenceContext === undefined
953
- ? verdict.payload
954
- : { ...verdict.payload, referenceContext: referenceResult.referenceContext }
960
+ ? { ...verdict.payload, text: hintedText }
961
+ : { ...verdict.payload, text: hintedText, referenceContext: referenceResult.referenceContext }
955
962
 
956
963
  const routedTag = await formatChannelTag(payload.workspace, payload.chat)
957
964
  logger.info(