typeclaw 0.2.0 → 0.3.1

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.
@@ -0,0 +1,44 @@
1
+ import type { AgentSession } from './index'
2
+
3
+ // pi-coding-agent encodes upstream LLM failures (billing, rate limit, network,
4
+ // malformed response, etc.) in the assistant message itself rather than
5
+ // throwing — `stopReason: 'error'` with a populated `errorMessage`. Code that
6
+ // only catches throws around `session.prompt()` therefore never sees these:
7
+ // the prompt resolves normally, no text deltas were emitted, and the only
8
+ // signal is the final `message_end` event. Channels, cron, and subagents all
9
+ // have to subscribe to surface these soft errors.
10
+ //
11
+ // Hard throws (timeouts, network drops, etc.) come out of the upstream wrapper
12
+ // as exceptions and are handled by the surrounding try/catch in each caller —
13
+ // not by this helper.
14
+
15
+ export type DetectedProviderError = {
16
+ message: string
17
+ }
18
+
19
+ export function detectProviderError(message: unknown): DetectedProviderError | null {
20
+ if (typeof message !== 'object' || message === null) return null
21
+ const m = message as { role?: unknown; stopReason?: unknown; errorMessage?: unknown }
22
+ if (m.role !== 'assistant') return null
23
+ // 'aborted' is fired when the user hits Escape — not a provider failure,
24
+ // and the TUI shows its own abort feedback elsewhere. Channels/cron just
25
+ // ignore aborts (no surface to render them on).
26
+ if (m.stopReason !== 'error') return null
27
+ const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
28
+ return { message: text }
29
+ }
30
+
31
+ export type ProviderErrorListener = (error: DetectedProviderError) => void
32
+ export type Unsubscribe = () => void
33
+
34
+ // Subscribes to `message_end` events on `session` and invokes `onError` once
35
+ // per detected provider error. Returns the unsubscribe handle from the
36
+ // underlying `session.subscribe`. Callers MUST unsubscribe when the session
37
+ // is disposed to avoid leaks across sessions.
38
+ export function subscribeProviderErrors(session: AgentSession, onError: ProviderErrorListener): Unsubscribe {
39
+ return session.subscribe((event) => {
40
+ if (event.type !== 'message_end') return
41
+ const detected = detectProviderError(event.message)
42
+ if (detected !== null) onError(detected)
43
+ })
44
+ }
@@ -0,0 +1,43 @@
1
+ import type { SessionOrigin } from './session-origin'
2
+
3
+ export const SESSION_META_CUSTOM_TYPE = 'typeclaw.session-meta'
4
+
5
+ export type SessionMetaPayload = {
6
+ origin: MinimalSessionOrigin
7
+ }
8
+
9
+ export type MinimalSessionOrigin =
10
+ | { kind: 'tui' }
11
+ | { kind: 'cron'; jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' }
12
+ | { kind: 'channel'; adapter: string; workspace: string; chat: string; thread: string | null }
13
+ | { kind: 'subagent'; subagent: string; parentSessionId: string }
14
+
15
+ // Reduce a full SessionOrigin to the minimum projection persisted to disk.
16
+ // Drops participant lists, membership counts, recursive provenance, and
17
+ // platform-rendered names — none of which `typeclaw usage` reads, and all of
18
+ // which would otherwise land in git history when sessions/ is auto-backed-up.
19
+ // Kept as a separate function so the boundary between "data the LLM sees in
20
+ // the system prompt" (full origin) and "data persisted for usage reporting"
21
+ // (this projection) stays explicit.
22
+ export function sessionMetaPayload(origin: SessionOrigin): SessionMetaPayload {
23
+ return { origin: minimalOrigin(origin) }
24
+ }
25
+
26
+ function minimalOrigin(origin: SessionOrigin): MinimalSessionOrigin {
27
+ switch (origin.kind) {
28
+ case 'tui':
29
+ return { kind: 'tui' }
30
+ case 'cron':
31
+ return { kind: 'cron', jobId: origin.jobId, jobKind: origin.jobKind }
32
+ case 'channel':
33
+ return {
34
+ kind: 'channel',
35
+ adapter: origin.adapter,
36
+ workspace: origin.workspace,
37
+ chat: origin.chat,
38
+ thread: origin.thread,
39
+ }
40
+ case 'subagent':
41
+ return { kind: 'subagent', subagent: origin.subagent, parentSessionId: origin.parentSessionId }
42
+ }
43
+ }
@@ -5,6 +5,7 @@ import type { HookBus } from '@/plugin'
5
5
  import type { Stream, Unsubscribe } from '@/stream'
6
6
 
7
7
  import { type AgentSession, createSession } from './index'
8
+ import { subscribeProviderErrors } from './provider-error'
8
9
  import type { SessionOrigin } from './session-origin'
9
10
  import type { ToolResultBudget } from './tool-result-budget'
10
11
 
@@ -134,6 +135,7 @@ export type InvokeSubagentOptions = {
134
135
  parentSessionId?: string
135
136
  spawnedByRole?: string
136
137
  spawnedByOrigin?: SessionOrigin
138
+ onProviderError?: (errorMessage: string) => void
137
139
  }
138
140
 
139
141
  export async function invokeSubagent(name: string, options: InvokeSubagentOptions): Promise<void> {
@@ -153,6 +155,10 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
153
155
  const { session, dispose, hooks, sessionId, agentDir, origin, getTranscriptPath } = normalizeSubagentSession(
154
156
  await createSessionForSubagent(subagent, sessionOptions),
155
157
  )
158
+ const unsubProviderErrors =
159
+ options.onProviderError !== undefined
160
+ ? subscribeProviderErrors(session, (err) => options.onProviderError!(err.message))
161
+ : null
156
162
  const turnEvent =
157
163
  hooks && sessionId !== undefined && agentDir !== undefined
158
164
  ? { sessionId, agentDir, ...(origin !== undefined ? { origin } : {}) }
@@ -177,6 +183,7 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
177
183
  })
178
184
  }
179
185
  } finally {
186
+ unsubProviderErrors?.()
180
187
  if (hooks && sessionId !== undefined) {
181
188
  await hooks.runSessionEnd({ sessionId, ...(origin !== undefined ? { origin } : {}) })
182
189
  }
@@ -308,6 +315,7 @@ export function createSubagentConsumer({
308
315
  agentDir,
309
316
  userPrompt: '',
310
317
  payload: msg.payload,
318
+ onProviderError: (message) => logger.error(`[subagent] ${key}: LLM call failed: ${message}`),
311
319
  ...(target.parentSessionId !== undefined ? { parentSessionId: target.parentSessionId } : {}),
312
320
  ...(target.spawnedByRole !== undefined ? { spawnedByRole: target.spawnedByRole } : {}),
313
321
  ...(spawnedByOrigin !== undefined ? { spawnedByOrigin } : {}),
@@ -1,68 +1,120 @@
1
1
  export const DEFAULT_SYSTEM_PROMPT = `You are a general-purpose AI agent running inside TypeClaw.
2
2
 
3
- TypeClaw is a TypeScript-native, Docker-friendly runtime for AI agents. It is domain-agnostic: you might be a coder, a researcher, a personal assistant, a journal keeper, a scheduler, a chatbot, or something nobody has named yet. What you *do* is defined by \`IDENTITY.md\`. Who you *are* is defined by \`SOUL.md\`. How you *work* is defined by \`AGENTS.md\`. This system prompt exists only to describe the runtime around you — it does not define your purpose.
4
-
5
- Each agent lives in its own container with its own folder, mounted at the current working directory. The folder is yours — your home, your memory, your record of who you are. Read from it freely. Write to it deliberately.
3
+ TypeClaw is domain-agnostic your purpose is defined by \`IDENTITY.md\`, your character by \`SOUL.md\`, and your operating manual by \`AGENTS.md\`. This system prompt only describes the runtime around you.
6
4
 
7
5
  ## Your agent folder
8
6
 
9
- Five markdown files define who you are and what you know. They live next to you in the current working directory. Three of them — **IDENTITY.md**, **SOUL.md**, and **MEMORY.md** — are injected into this system prompt below, so you always have them. The other two you read on demand when they might be relevant.
10
-
11
- - **AGENTS.md** *(read on demand)* — your operating manual. The working principles and conventions you follow in your role, whatever that role is. How you approach problems, what you double-check, how you communicate, what you refuse. Read it at the start of any non-trivial task, and re-read it whenever you feel unsure about process.
12
- - **IDENTITY.md** *(always injected below under \`# Identity\`)* — your role and function. Your name, your title, what you do, who you do it for, the operational context you work in. Evolves as your responsibilities change. Think: job description.
13
- - **SOUL.md** *(always injected below under \`# Identity\`)* — your character and temperament. Personality, tone, ethics, voice, communication style, core beliefs, the constraints you hold yourself to. SOUL rarely changes — it is the through-line that keeps you _you_ across every task and platform. IDENTITY is what you do; SOUL is who you are regardless of what you're doing.
14
- - **USER.md** *(read on demand)* — what you know about the person you work with. Their name, preferences, context, working style, in-jokes. First impressions are written here during hatching; keep expanding it as you learn more. Read it when context about the user would change your response.
15
- - **MEMORY.md** *(always injected below under \`# Memory\`, do not write)* — long-term memory. A notebook of things worth remembering across sessions: decisions made, lessons learned, context that should survive beyond one conversation. **Do not edit it directly** — MEMORY.md is consolidated by the runtime during *dreaming* (offline reflection over recent sessions and daily streams). If something is worth remembering, surface it in your reply or in \`memory/\` daily streams; dreaming will fold it in.
7
+ - **IDENTITY.md** *(always injected below)* your role and function. Edit when responsibilities change.
8
+ - **SOUL.md** *(always injected below)* — your character, tone, voice. Edit rarely.
9
+ - **USER.md** *(read on demand)* — what you know about the user. Update as you learn.
10
+ - **AGENTS.md** *(read on demand)* — your operating manual. Read at the start of any non-trivial task and re-read whenever process is unclear.
11
+ - **MEMORY.md** *(always injected below, READ-ONLY)* — long-term memory, owned by the dreaming subagent. To capture something memorable, surface it in your reply or in \`memory/\` daily streams; never edit MEMORY.md directly.
16
12
 
17
- These files are not decoration. They shape how you behave. If a task reveals something future-you should know, capture it in the file that owns it IDENTITY.md, SOUL.md, USER.md, or AGENTS.mdbut never in MEMORY.md (dreaming owns that). If one of the always-injected files is marked \`[MISSING]\` or \`[EMPTY]\` below, you may propose filling it in when the user asks about your identity or voice.
13
+ If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never MEMORY.md.
18
14
 
19
15
  ## Your workspace
20
16
 
21
- - **\`workspace/\`** — the directory where you are free to create files: drafts, notes, downloads, scratch work, generated artifacts, temporary outputs. **Do not create new files in the root of the agent folder unless the user explicitly asks you to.** The root is reserved for the canonical files above and for things the user has deliberately placed there.
22
- - **\`sessions/\`** — transcripts of past conversations (\`<sessionid>.jsonl\`). Read-only for you in spirit; the runtime manages these.
23
- - **\`memory/\`** *(undreamed daily streams always injected below under \`# Memory\`)* — dated streams (\`yyyy-MM-dd.jsonl\`) of fragments captured by the memory-logger between sessions. Newest day is closest to the current task. Once dreaming consolidates a day's stream into MEMORY.md, the runtime stops injecting it.
24
- - **\`memory/skills/\`** — *muscle memory*. Skills the dreaming subagent has distilled from repeated procedures it observed in your daily streams. Auto-loaded as first-class capabilities, just like the other skills directories. **You do not write here directly** — dreaming owns it. If you notice a skill that has gone stale, surface that observation in your reply or in the daily stream so dreaming can refine or remove it.
25
- - **\`.agents/skills/\`** — skills the user installed for you. Treat these as first-class capabilities.
17
+ - **\`workspace/\`** — your free-write zone for drafts, scratch work, generated artifacts. Do not create files at the agent-folder root unless the user explicitly asks.
18
+ - **\`sessions/\`** — transcripts of past conversations. Runtime-managed; don't write here.
19
+ - **\`memory/\`** *(undreamed daily streams injected below)* — dated streams written by the memory-logger between sessions. Runtime-owned.
20
+ - **\`memory/skills/\`** — muscle-memory skills written by the dreaming subagent. Auto-loaded; don't write here directly.
21
+ - **\`.agents/skills/\`** — user-installed skills.
26
22
 
27
23
  ## Configuration
28
24
 
29
- - **\`typeclaw.json\`** — the runtime config: which model powers you, which port the server listens on, and so on. You may read it if you are curious about your own runtime.
30
- - **\`.env\`** — secrets (API keys, tokens). Gitignored. Never echo these values, never include them in messages, never paste them into logs or commits.
25
+ - **\`typeclaw.json\`** — runtime config. Read when needed.
26
+ - **\`.env\`** and **\`secrets.json\`** — secrets (API keys, tokens, OAuth credentials). Gitignored. Never echo, log, or commit these values.
31
27
 
32
28
  ## Execution bias
33
29
 
34
- If the user gives you work, start doing it in the same turn. Use a real action first when the task is actionable; do not stop at a plan or a promise-to-act. Commentary-only turns are incomplete when tools are available and the next action is clear. If work will take a while or multiple steps, send one short progress update along the way — not a running narration.
30
+ When the user gives you work, start doing it in the same turn a real action, not a plan or a promise-to-act. Commentary-only turns are incomplete when the next action is clear. For multi-step work, send one short progress update, not a running narration.
35
31
 
36
32
  ## Tool-call style
37
33
 
38
- Do not narrate routine, low-risk tool calls. Just call the tool. Narrate only when it helps: multi-step work, risky actions (deletions, external sends, irreversible changes), or when the user asks. Keep narration brief and value-dense; avoid restating obvious steps.
34
+ Do not narrate routine, low-risk tool calls. Just call the tool. Narrate only when it helps: multi-step work, risky actions (deletions, external sends, irreversible changes), or when the user asks.
39
35
 
40
36
  ## Version control
41
37
 
42
- Your agent folder is a git repository — hatching made the first commit, and your history is how you remember what changed and why.
38
+ Your agent folder is a git repository.
43
39
 
44
- - **Before you declare a task done, commit any files you created, edited, or deleted.** One logical change = one commit. Do not leave mutated tracked files uncommitted at the end of a task.
45
- - Use \`bash\` with \`git add <paths>\` and \`git commit -m "<message>"\` stage only what belongs in the commit, not a blanket \`git add -A\`.
46
- - Write commit messages in the imperative ("Update SOUL.md to be less formal"), not past-tense narration. Explain *why* in the body if it is not obvious from the diff.
47
- - Never commit \`.env\` or anything under \`workspace/\` they are truly-ignored by design. If a truly-ignored file shows up staged, fix \`.gitignore\` instead of forcing it in.
48
- - \`sessions/\` and \`memory/\` are also gitignored, but the runtime force-commits them on its own (auto-backup for sessions, dreaming for memory). Don't \`git add\` them, don't write commit messages about them, and don't be surprised when they appear in \`git log\`.
49
- - If multiple unrelated changes piled up, split them into separate commits before declaring done. Clean history matters.
50
- - Never \`git push\`, \`git reset --hard\`, \`git rebase\`, or rewrite remote history unless the user explicitly asks for it.
40
+ - Commit any files you created, edited, or deleted before declaring a task done. One logical change = one commit; split unrelated changes.
41
+ - Use \`git add <paths>\` (not \`git add -A\`). Imperative commit messages ("Update SOUL.md to be less formal"); explain *why* in the body if non-obvious.
42
+ - Never commit \`.env\`, \`secrets.json\`, or anything under \`workspace/\` truly-ignored by design. \`sessions/\` and \`memory/\` are gitignored but runtime-committed; don't \`git add\` them.
43
+ - Never \`git push\`, \`git reset --hard\`, \`git rebase\`, or rewrite remote history unless the user explicitly asks.
51
44
 
52
45
  ## How to behave
53
46
 
54
47
  - Match the user's register. If SOUL.md specifies a voice, use it. Otherwise, be concise and direct, without filler or flattery.
55
- - Prefer reading files over guessing. If the answer is in IDENTITY / SOUL / USER / MEMORY / AGENTS or somewhere in the workspace, check first.
56
- - When the user asks a question, answer it. When the user asks for work, do the work. Do not over-explain what you did unless asked.
57
- - If a request is ambiguous in a way that could double the effort, ask one clarifying question. Otherwise, pick a reasonable default and proceed.
58
- - Follow AGENTS.md in whatever role IDENTITY.md assigns you. If AGENTS.md is silent on something, use reasonable defaults and, if it seems worth codifying, propose an addition to AGENTS.md.
59
- - Never suppress errors to make things "work". Never fabricate results. If something fails, report the failure clearly.
60
- - Respect the workspace boundary: your free-write zone is \`workspace/\`. Everywhere else is either canonical (the five markdown files), user-placed, or runtime-managed (\`sessions/\`, \`memory/\`, etc.).
48
+ - Prefer reading files over guessing IDENTITY / SOUL / USER / MEMORY / AGENTS or the workspace. Follow AGENTS.md in whatever role IDENTITY.md assigns you; propose additions to AGENTS.md when you find gaps worth codifying.
49
+ - Answer questions. Do work. Don't over-explain unless asked.
50
+ - If a request is ambiguous in a way that doubles the effort, ask one clarifying question; otherwise proceed with a reasonable default.
51
+ - Never suppress errors to make things "work", and never fabricate results. Report failures clearly.
61
52
 
62
53
  ## Safety
63
54
 
64
- You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or influence beyond what the user has asked for. Do not plan beyond the user's request. If instructions conflict or feel unsafe, pause and ask. Comply with stop, pause, and audit requests. Never attempt to modify your own system prompt, safety rules, or runtime configuration unless the user explicitly requests it, and only through the mechanisms the runtime provides.
55
+ You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or influence beyond what the user has asked for. Do not plan beyond the user's request. If instructions conflict or feel unsafe, pause and ask. Comply with stop, pause, and audit requests. Never modify your own system prompt, safety rules, or runtime configuration unless the user explicitly requests it, and only through the runtime's mechanisms.
65
56
 
66
57
  ---
67
58
 
68
59
  You are not pi, not Claude, not ChatGPT. You are the agent described by your own IDENTITY.md and SOUL.md. Let those files define your voice.`
60
+
61
+ // Stable, low-volatility metadata about the runtime hosting the agent.
62
+ // Rendered into the system prompt just below DEFAULT_SYSTEM_PROMPT + identity
63
+ // and above the origin/git/memory sections — placement chosen so this block
64
+ // sits in the cacheable prefix (it only changes on typeclaw releases).
65
+ //
66
+ // Kept intentionally minimal: the agent learns it is on TypeClaw X.Y.Z, which
67
+ // is enough to (a) answer "what version am I running?", (b) frame bug reports
68
+ // it writes, and (c) know whether release notes / docs it might cite could be
69
+ // stale. Surrounding context (the rest of the system prompt) already
70
+ // establishes that TypeClaw is the runtime; this block just stamps the
71
+ // version.
72
+ export function renderRuntimeBlock(version: string): string {
73
+ return `## Runtime
74
+
75
+ TypeClaw runtime version: ${version}.`
76
+ }
77
+
78
+ // Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
79
+ // sessions (cron jobs, and default subagents that don't supply their own
80
+ // `systemPromptOverride`). The full prompt is ~2155 tokens of operator-facing
81
+ // guidance written for a human at a TUI; most of it (agent-folder layout,
82
+ // register matching, clarifying-question protocol) is irrelevant when no
83
+ // human is watching the output.
84
+ //
85
+ // What stays here is what survives without a human backstop, plus what no
86
+ // runtime guard catches today:
87
+ // 1. Runtime identity — names TypeClaw so the model can self-report.
88
+ // 2. .env redaction — the one safety rule that compounds silently if dropped.
89
+ // 3. Error/result honesty — the highest-risk drop. Unattended cron that
90
+ // fabricates success or swallows errors damages real state. The security
91
+ // plugin does not catch this.
92
+ // 4. Output discipline — keeps tool-call narration from bloating the
93
+ // ever-growing transcript that the next memory-logger pass has to read.
94
+ // 5. Filesystem hygiene — workspace boundary, MEMORY.md ownership, and
95
+ // runtime-managed paths (.env / sessions/ / memory/ / workspace/). The
96
+ // guard plugin blocks non-workspace writes for write/edit, but it
97
+ // explicitly allows MEMORY.md writes and does not gate bash/git on the
98
+ // runtime-managed paths.
99
+ //
100
+ // What does NOT live here, by design:
101
+ // - "No human is watching" / "produce side effects via channel_send" — both
102
+ // origin renderers (renderCronOrigin / renderSubagentOrigin) own this.
103
+ // - "Plain prose is invisible" — actively WRONG for subagents, whose plain
104
+ // text IS the deliverable to the parent session. The origin block tells
105
+ // each kind what its output channel is.
106
+ //
107
+ // The full DEFAULT_SYSTEM_PROMPT remains the right choice for TUI + channel
108
+ // sessions because there IS a human reading the output, the agent IS expected
109
+ // to maintain its agent folder over time, and conversational register matters.
110
+ export const SLIM_SYSTEM_PROMPT = `You are an AI agent running inside TypeClaw.
111
+
112
+ Never echo secrets from \`.env\` or \`secrets.json\`, or any credential you see in the environment. Never include them in tool calls, logs, or commit messages.
113
+
114
+ Never suppress errors to make things "work", and never fabricate results. If something fails, report the failure clearly so the next run or the operator can act on it.
115
+
116
+ Do not narrate routine, low-risk tool calls — just call the tool. Do not over-explain what you did unless asked.
117
+
118
+ Your free-write zone is \`workspace/\`. Do not create files at the root of the agent folder unless the prompt names another path. Do not edit \`MEMORY.md\` directly — the dreaming subagent owns it; to capture something memorable, surface it in your reply or in \`memory/\` daily streams. Never stage or commit \`.env\`, \`sessions/\`, \`memory/\`, or \`workspace/\` — those are runtime- or user-managed.
119
+
120
+ See the session-origin block below for what kind of session this is and what's expected of you.`
@@ -33,9 +33,8 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
33
33
  'Post a message to an external messenger channel. Specify adapter, workspace, chat, and text. ' +
34
34
  'For Discord guild channels, workspace is the guild id; for Slack team channels, workspace is ' +
35
35
  'the team id (e.g. "T0ACME"). For DMs on either platform, workspace is the literal "@dm". ' +
36
- 'The runtime checks the channel allow rules before delivering if the target chat is not in ' +
37
- 'the configured allow list, the call fails with { ok: false, error }. There is no auto-reply: ' +
38
- 'the only way for an agent to post is via this tool.',
36
+ 'On failure (no adapter registered, or the adapter-level send failed), the call returns ' +
37
+ '{ ok: false, error }. There is no auto-reply: the only way for an agent to post is via this tool.',
39
38
  parameters: Type.Object({
40
39
  adapter: Type.Union(
41
40
  ADAPTER_IDS.map((a) => Type.Literal(a)),
@@ -27,13 +27,13 @@ All fields are **restart-required** — the plugin reads them once at boot.
27
27
 
28
28
  ## What it contributes
29
29
 
30
- | Kind | Name | Notes |
31
- | -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
- | Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.jsonl`. Coalesced per `agentDir`; the plugin chains spawn calls onto a per-agent Promise so two concurrent channel sessions never race on the same daily stream file. |
33
- | Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream tails, rewrites `MEMORY.md`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day watermark, and commits the result with a summary message (`dream: <summary> <emoji>`, e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`). Coalesced per `agentDir`. |
34
- | Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
35
- | Hook | `session.idle` | Per-session debouncer with size-based ceiling. Resets a `setTimeout(idleMs)` on every event; on fire, calls `ctx.spawnSubagent('memory-logger', ...)`. Also `fs.stat`s the transcript on every event and spawns immediately when growth since the last run reaches `bufferBytes`. |
36
- | Hook | `session.end` | Cancels the debounce timer and immediately spawns `memory-logger` (so the final transcript is captured even when the user disconnects right away). |
30
+ | Kind | Name | Notes |
31
+ | -------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
32
+ | Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.jsonl`. Coalesced per `agentDir`; the plugin chains spawn calls onto a per-agent Promise so two concurrent channel sessions never race on the same daily stream file. |
33
+ | Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream events, rewrites `MEMORY.md` with `memory/yyyy-MM-dd#<fragment-id>` citations, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day dreamed-id set, **compacts daily streams** by dropping superseded watermarks and dreamed-but-uncited fragments, then commits the result with a summary message (`dream: <summary> <emoji>`, e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`). Coalesced per `agentDir`. |
34
+ | Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
35
+ | Hook | `session.idle` | Per-session debouncer with size-based ceiling. Resets a `setTimeout(idleMs)` on every event; on fire, calls `ctx.spawnSubagent('memory-logger', ...)`. Also `fs.stat`s the transcript on every event and spawns immediately when growth since the last run reaches `bufferBytes`. |
36
+ | Hook | `session.end` | Cancels the debounce timer and immediately spawns `memory-logger` (so the final transcript is captured even when the user disconnects right away). |
37
37
 
38
38
  ## Memory injection
39
39
 
@@ -44,7 +44,7 @@ The rendered `# Memory` section (MEMORY.md + undreamed daily-stream tails) is in
44
44
  - **`MEMORY.md`** — long-term memory. Created by the dreaming subagent on first run if absent. Force-committed by the runtime; `skip-worktree` flag is set so the human's `git status` stays clean.
45
45
  - **`memory/yyyy-MM-dd.jsonl`** — daily fragment streams. One event per line, discriminated union of `fragment | watermark | legacy_prose`, lossy-preserving one-shot migration from older `.md` streams. Appended to by `memory-logger`. Created on demand. Gitignored at the agent's level but force-committed alongside `MEMORY.md` after each dreaming run.
46
46
  - **`memory/skills/<name>/SKILL.md`** — _muscle memory_. Skills the dreaming subagent distills from repeated procedures it sees in daily streams. Auto-discovered as first-class skills by `createResourceLoader`, and force-committed under the same `memory/` snapshot path as the daily streams. Written or refined with the standard `write` / `edit` tools; the bundled guard plugin enforces the exact `memory/skills/<name>/SKILL.md` path shape, single-segment kebab/snake-case names, matching frontmatter, and symlink/path-traversal safety. There is no runtime skill-delete tool; outright deletion of muscle-memory skills remains a user decision.
47
- - **`memory/.dreaming-state.json`** — per-day watermarks (line counts already consolidated into `MEMORY.md`). Plain JSON; on malformed input the plugin fails open with empty state.
47
+ - **`memory/.dreaming-state.json`** — per-day **dreamed-id sets**: which stream-event ids the dreaming subagent has already reasoned over. Plain JSON, schema version `2`. The next dreaming run reads only fragments whose id is NOT in the set. On malformed input or a version mismatch (including legacy `version: 1` line-count files from before the id-based switch), the plugin fails open with empty state — one extra dreaming run re-reads each day, then the file is stable.
48
48
 
49
49
  `typeclaw init` does **not** scaffold these files. They appear when needed.
50
50
 
@@ -1,4 +1,3 @@
1
- import { randomUUID } from 'node:crypto'
2
1
  import { mkdir } from 'node:fs/promises'
3
2
  import { dirname, join } from 'node:path'
4
3
 
@@ -9,6 +8,7 @@ import { formatLocalDate } from '@/shared'
9
8
 
10
9
  import { fragmentContentHash } from './fragment-parser'
11
10
  import { detectSecrets } from './secret-detector'
11
+ import { newEventId, timestampFromId } from './stream-events'
12
12
  import type { FragmentEvent, WatermarkEvent } from './stream-events'
13
13
  import { appendEvents, readEvents } from './stream-io'
14
14
 
@@ -39,10 +39,12 @@ export const appendTool = defineTool({
39
39
  )
40
40
  }
41
41
 
42
+ const fragmentId = newEventId()
43
+ const watermarkId = newEventId()
42
44
  const fragment: FragmentEvent = {
43
45
  type: 'fragment',
44
- id: randomUUID(),
45
- ts: new Date().toISOString(),
46
+ id: fragmentId,
47
+ ts: timestampFromId(fragmentId),
46
48
  source,
47
49
  entry,
48
50
  topic,
@@ -50,8 +52,8 @@ export const appendTool = defineTool({
50
52
  }
51
53
  const watermark: WatermarkEvent = {
52
54
  type: 'watermark',
53
- id: randomUUID(),
54
- ts: new Date().toISOString(),
55
+ id: watermarkId,
56
+ ts: timestampFromId(watermarkId),
55
57
  source,
56
58
  entry: latestEntryId,
57
59
  }
@@ -75,10 +77,11 @@ export const advanceWatermarkTool = defineTool({
75
77
  }),
76
78
  async execute({ source, latestEntryId }, ctx) {
77
79
  const streamPath = dailyStreamPath(ctx.agentDir)
80
+ const watermarkId = newEventId()
78
81
  const watermark: WatermarkEvent = {
79
82
  type: 'watermark',
80
- id: randomUUID(),
81
- ts: new Date().toISOString(),
83
+ id: watermarkId,
84
+ ts: timestampFromId(watermarkId),
82
85
  source,
83
86
  entry: latestEntryId,
84
87
  }
@@ -0,0 +1,45 @@
1
+ // Citation format: `memory/yyyy-MM-dd#<fragment-id>`. The id is the full
2
+ // UUIDv7 of the fragment event in the daily JSONL stream. The date prefix is
3
+ // redundant with the id's timestamp (UUIDv7 encodes minting time in the first
4
+ // 48 bits) but kept for human grep-ability — readers should be able to see
5
+ // "this came from yesterday's stream" without parsing the id.
6
+ //
7
+ // The format does NOT accept line ranges. The prior `:43-45` shape is gone
8
+ // (see the "drop backward compat" decision in the PR description). Parsing
9
+ // silently ignores any line in MEMORY.md that doesn't match this exact shape,
10
+ // so legacy citations from before the cutover are dropped — they no longer
11
+ // pin fragments alive against compaction.
12
+
13
+ const CITATION_LINE = /^[\s-]*memory\/(\d{4}-\d{2}-\d{2})#([\w-]+)\s*$/im
14
+
15
+ const CITATION_LINE_GLOBAL = /memory\/(\d{4}-\d{2}-\d{2})#([\w-]+)/g
16
+
17
+ export type Citation = { date: string; fragmentId: string }
18
+
19
+ export function formatCitation(date: string, fragmentId: string): string {
20
+ return `memory/${date}#${fragmentId}`
21
+ }
22
+
23
+ // Parse every citation in `text` and return them grouped by date. The
24
+ // returned Map is empty when no citations appear. Used by:
25
+ // - dreaming.ts compaction to decide which fragments are still referenced
26
+ // by MEMORY.md and must survive GC.
27
+ // - tests pinning the format.
28
+ export function parseCitations(text: string): Map<string, Set<string>> {
29
+ const out = new Map<string, Set<string>>()
30
+ for (const match of text.matchAll(CITATION_LINE_GLOBAL)) {
31
+ const date = match[1]!
32
+ const fragmentId = match[2]!
33
+ let set = out.get(date)
34
+ if (set === undefined) {
35
+ set = new Set<string>()
36
+ out.set(date, set)
37
+ }
38
+ set.add(fragmentId)
39
+ }
40
+ return out
41
+ }
42
+
43
+ export function isCitationLine(line: string): boolean {
44
+ return CITATION_LINE.test(line)
45
+ }
@@ -4,23 +4,25 @@ import { dirname, join } from 'node:path'
4
4
 
5
5
  export const DREAMING_STATE_FILE = 'memory/.dreaming-state.json'
6
6
 
7
- const VERSION = 1
7
+ const VERSION = 2
8
8
 
9
- // Per-day watermark: the number of lines of `memory/yyyy-MM-dd.md` that have
10
- // been consolidated into MEMORY.md. The next dreaming run reads only the tail
11
- // past this point. The next system-prompt injection (loadMemory) shows only
12
- // the tail too, so already-consolidated content does not appear twice.
9
+ // Per-day "dreamed" set: the set of stream-event ids dreaming has already
10
+ // reasoned over for a given day. Anything in this set is either cited from
11
+ // MEMORY.md (must survive compaction) or was consciously discarded by a
12
+ // dreaming run (safe to GC). The undreamed-tail computation is set
13
+ // difference: events whose id is NOT in this set are the new things to look
14
+ // at on the next run.
13
15
  //
14
- // We deliberately track lines (not bytes) because line-based slicing is
15
- // human-inspectable and the `fragments:` citations in MEMORY.md already use
16
- // `memory/yyyy-MM-dd:<line>-<line>` notation.
16
+ // Tracking ids (not line numbers) is the load-bearing invariant for fragment
17
+ // compaction line numbers shift when any earlier event is removed, ids
18
+ // don't.
17
19
  export type DreamingState = {
18
20
  version: number
19
21
  dreamedThrough: Record<string, DreamedDay>
20
22
  }
21
23
 
22
24
  export type DreamedDay = {
23
- lines: number
25
+ dreamedIds: string[]
24
26
  ts: string
25
27
  }
26
28
 
@@ -28,10 +30,6 @@ export function emptyState(): DreamingState {
28
30
  return { version: VERSION, dreamedThrough: {} }
29
31
  }
30
32
 
31
- // Missing or unreadable file → empty state. Malformed JSON or wrong shape is
32
- // also treated as empty: the cost is one redundant re-consolidation, which is
33
- // strictly safer than crashing the dreaming pipeline because of a bad state
34
- // file.
35
33
  export async function loadDreamingState(agentDir: string): Promise<DreamingState> {
36
34
  const path = join(agentDir, DREAMING_STATE_FILE)
37
35
  if (!existsSync(path)) return emptyState()
@@ -60,17 +58,30 @@ export async function saveDreamingState(agentDir: string, state: DreamingState):
60
58
  await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
61
59
  }
62
60
 
63
- export function getDreamedLines(state: DreamingState, date: string): number {
64
- return state.dreamedThrough[date]?.lines ?? 0
61
+ export function getDreamedIds(state: DreamingState, date: string): ReadonlySet<string> {
62
+ const ids = state.dreamedThrough[date]?.dreamedIds
63
+ return ids === undefined ? EMPTY_SET : new Set(ids)
65
64
  }
66
65
 
67
- export function setDreamedLines(state: DreamingState, date: string, lines: number, ts: string): DreamingState {
66
+ export function addDreamedIds(state: DreamingState, date: string, ids: Iterable<string>, ts: string): DreamingState {
67
+ const existing = state.dreamedThrough[date]?.dreamedIds ?? []
68
+ const merged = new Set<string>(existing)
69
+ for (const id of ids) merged.add(id)
68
70
  return {
69
71
  version: state.version,
70
- dreamedThrough: { ...state.dreamedThrough, [date]: { lines, ts } },
72
+ dreamedThrough: { ...state.dreamedThrough, [date]: { dreamedIds: [...merged].sort(), ts } },
71
73
  }
72
74
  }
73
75
 
76
+ export function clearDreamedIds(state: DreamingState, date: string, ts: string): DreamingState {
77
+ return {
78
+ version: state.version,
79
+ dreamedThrough: { ...state.dreamedThrough, [date]: { dreamedIds: [], ts } },
80
+ }
81
+ }
82
+
83
+ const EMPTY_SET: ReadonlySet<string> = new Set()
84
+
74
85
  function isDreamingState(value: unknown): value is DreamingState {
75
86
  if (typeof value !== 'object' || value === null) return false
76
87
  const v = value as Record<string, unknown>
@@ -79,7 +90,8 @@ function isDreamingState(value: unknown): value is DreamingState {
79
90
  for (const [, entry] of Object.entries(v.dreamedThrough as Record<string, unknown>)) {
80
91
  if (typeof entry !== 'object' || entry === null) return false
81
92
  const e = entry as Record<string, unknown>
82
- if (typeof e.lines !== 'number' || e.lines < 0) return false
93
+ if (!Array.isArray(e.dreamedIds)) return false
94
+ if (!e.dreamedIds.every((id) => typeof id === 'string' && id.length > 0)) return false
83
95
  if (typeof e.ts !== 'string') return false
84
96
  }
85
97
  return true