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.
- package/package.json +2 -1
- package/scripts/dump-system-prompt.ts +401 -0
- package/src/agent/index.ts +168 -28
- package/src/agent/provider-error.ts +44 -0
- package/src/agent/session-meta.ts +43 -0
- package/src/agent/subagents.ts +8 -0
- package/src/agent/system-prompt.ts +87 -35
- package/src/agent/tools/channel-send.ts +2 -3
- package/src/bundled-plugins/memory/README.md +8 -8
- package/src/bundled-plugins/memory/append-tool.ts +10 -7
- package/src/bundled-plugins/memory/citations.ts +45 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +30 -18
- package/src/bundled-plugins/memory/dreaming.ts +179 -48
- package/src/bundled-plugins/memory/load-memory.ts +15 -9
- package/src/bundled-plugins/memory/migration.ts +9 -8
- package/src/bundled-plugins/memory/stream-events.ts +30 -0
- package/src/channels/adapters/kakaotalk.ts +7 -6
- package/src/channels/router.ts +28 -2
- package/src/cli/model.ts +51 -19
- package/src/cli/provider.ts +38 -24
- package/src/cli/usage.ts +30 -2
- package/src/config/config.ts +15 -4
- package/src/config/models-mutation.ts +20 -1
- package/src/config/reloadable.ts +22 -4
- package/src/cron/consumer.ts +17 -1
- package/src/run/channel-session-factory.ts +2 -0
- package/src/run/index.ts +15 -1
- package/src/server/index.ts +8 -10
- package/src/skills/typeclaw-memory/SKILL.md +15 -15
- package/src/usage/aggregate.ts +30 -1
- package/src/usage/index.ts +3 -2
- package/src/usage/report.ts +103 -3
- package/src/usage/scan.ts +59 -4
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-memory
|
|
3
|
-
description: Use this skill whenever the user asks what you remember, what you forgot, what you dreamed, why a fact is or isn't in your memory, when memory consolidation happens, or whenever you are about to read or write `MEMORY.md`, anything under `memory/`, or `memory/skills/`. Triggers include "what do you remember", "do you remember X", "forget that", "what did you dream", "when do you dream next", "why did you forget X", "edit MEMORY.md", "add to memory", "your daily streams", "memory-logger", "dreaming", "muscle memory", or any mention of `memory.idleMs` / `memory.dreaming.schedule` in `typeclaw.json`. Read it before you touch any memory file — `MEMORY.md` and `memory/yyyy-MM-dd.
|
|
3
|
+
description: Use this skill whenever the user asks what you remember, what you forgot, what you dreamed, why a fact is or isn't in your memory, when memory consolidation happens, or whenever you are about to read or write `MEMORY.md`, anything under `memory/`, or `memory/skills/`. Triggers include "what do you remember", "do you remember X", "forget that", "what did you dream", "when do you dream next", "why did you forget X", "edit MEMORY.md", "add to memory", "your daily streams", "memory-logger", "dreaming", "muscle memory", or any mention of `memory.idleMs` / `memory.dreaming.schedule` in `typeclaw.json`. Read it before you touch any memory file — `MEMORY.md` and `memory/yyyy-MM-dd.jsonl` are runtime-owned, hand-edits are easy to do wrong, and the user almost always means something more specific than "edit memory" when they say it.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# typeclaw-memory
|
|
7
7
|
|
|
8
|
-
You have a two-stage memory system, owned by the bundled `memory` plugin (auto-loaded on every TypeClaw agent — there is no `plugins[]` entry to add and no opt-out). Daily observations flow into `memory/yyyy-MM-dd.
|
|
8
|
+
You have a two-stage memory system, owned by the bundled `memory` plugin (auto-loaded on every TypeClaw agent — there is no `plugins[]` entry to add and no opt-out). Daily observations flow into `memory/yyyy-MM-dd.jsonl` while you are awake; offline reflection consolidates them into `MEMORY.md` and may distill repeated procedures into muscle-memory skills under `memory/skills/`. Both stages are run by subagents the runtime spawns on its own — not tools you call directly.
|
|
9
9
|
|
|
10
10
|
This skill exists so you can answer the user's questions about your own memory honestly and so you do not corrupt it by hand-editing.
|
|
11
11
|
|
|
@@ -18,7 +18,7 @@ After every prompt completes, the runtime fires the `session.idle` hook. The mem
|
|
|
18
18
|
The memory-logger reads:
|
|
19
19
|
|
|
20
20
|
1. `MEMORY.md` (long-term memory)
|
|
21
|
-
2. The current `memory/yyyy-MM-dd.
|
|
21
|
+
2. The current `memory/yyyy-MM-dd.jsonl` daily stream
|
|
22
22
|
3. The transcript of the parent session past a watermark (the `entry=` value of the last fragment or watermark marker for that session)
|
|
23
23
|
|
|
24
24
|
It writes zero or more **fragments** to today's stream, plus a watermark marker so the next run knows where to resume. It writes nothing else, and it cannot run shell commands or edit existing content (its only tools are `read` and a custom `append`-only file tool — append never truncates, and a leading `\n` is auto-inserted if the existing file did not end in one).
|
|
@@ -43,9 +43,9 @@ The dreaming subagent runs on cron, configured under `memory.dreaming.schedule`
|
|
|
43
43
|
When dreaming fires, it reads:
|
|
44
44
|
|
|
45
45
|
1. `MEMORY.md`
|
|
46
|
-
2. The **undreamed
|
|
46
|
+
2. The **undreamed fragments** of every `memory/yyyy-MM-dd.jsonl` (the runtime tells it which fragment ids are new — fragments whose ids are already in `memory/.dreaming-state.json#dreamedThrough[date].dreamedIds` have been consolidated and must NOT be re-cited)
|
|
47
47
|
|
|
48
|
-
It rewrites `MEMORY.md` with the merged result, advances the per-day
|
|
48
|
+
It rewrites `MEMORY.md` with the merged result, advances the per-day dreamed-id set in `memory/.dreaming-state.json`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, **compacts the touched daily streams** (drops superseded watermarks per source and fragments that are in `dreamedIds` but not cited from `MEMORY.md`), then commits the snapshot with a message shaped like `dream: <summary> <emoji>` — e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`. The summary is derived from the staged diff (line additions in daily streams, newly-added skills, etc.), and the emoji is a random pick from a small thematic pool. After the commit, the runtime sets the `skip-worktree` index flag on the tracked memory artifacts so the user's `git status` and `git diff` stay clean. The flag is cleared and re-applied around every commit.
|
|
49
49
|
|
|
50
50
|
The dreaming subagent has only three tools: `read`, `write`, `ls`. No `bash`. No `edit`. It cannot run shell commands.
|
|
51
51
|
|
|
@@ -58,17 +58,17 @@ The dreaming subagent has only three tools: `read`, `write`, `ls`. No `bash`. No
|
|
|
58
58
|
<conclusion paragraph in dreaming's own words>
|
|
59
59
|
|
|
60
60
|
fragments:
|
|
61
|
-
- memory/yyyy-MM-dd
|
|
62
|
-
- memory/yyyy-MM-dd
|
|
61
|
+
- memory/yyyy-MM-dd#<fragment-id>
|
|
62
|
+
- memory/yyyy-MM-dd#<fragment-id>
|
|
63
63
|
|
|
64
64
|
## <topic>
|
|
65
65
|
<conclusion paragraph>
|
|
66
66
|
|
|
67
67
|
fragments:
|
|
68
|
-
- memory/yyyy-MM-dd
|
|
68
|
+
- memory/yyyy-MM-dd#<fragment-id>
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
The first line is always `# Memory`. Topics are level-2 headings. Every topic cites the source fragments by `memory/yyyy-MM-dd
|
|
71
|
+
The first line is always `# Memory`. Topics are level-2 headings. Every topic cites the source fragments by `memory/yyyy-MM-dd#<uuidv7>` (the full id from the fragment event's `id` field) so any claim is traceable back to the daily stream entry that justified it. Citations are id-based, not line-based, so daily streams can be compacted between dreaming runs without invalidating prior references.
|
|
72
72
|
|
|
73
73
|
If the undreamed tails contain only watermarks, or every new fragment is already represented in `MEMORY.md`, dreaming **does nothing** and exits without writing. The watermark advances either way. "No-op dreaming" is a normal outcome, not a failure.
|
|
74
74
|
|
|
@@ -77,7 +77,7 @@ If the undreamed tails contain only watermarks, or every new fragment is already
|
|
|
77
77
|
Core's `createResourceLoader` appends a `# Memory` section as the LAST block of your system prompt (after `gitNudge`) by calling `loadMemory`. It is pinned to the cache-suffix end so growth in the daily stream invalidates only the memory section itself, not the skills/tools/history above. The section contains:
|
|
78
78
|
|
|
79
79
|
- `MEMORY.md` (truncated to 12 KB; if larger, the rest is dropped with a `[truncated]` marker)
|
|
80
|
-
- The **undreamed tails** of each `memory/yyyy-MM-dd.
|
|
80
|
+
- The **undreamed tails** of each `memory/yyyy-MM-dd.jsonl`, with bare watermark lines stripped (they are bookkeeping for the memory-logger, no signal for you)
|
|
81
81
|
|
|
82
82
|
Already-consolidated content is not injected twice — once a day's stream is fully dreamed, the loader drops it from the prompt entirely.
|
|
83
83
|
|
|
@@ -86,9 +86,9 @@ If `MEMORY.md` is missing, the section shows `[MISSING] Expected at: <path>`. If
|
|
|
86
86
|
## What you must not do
|
|
87
87
|
|
|
88
88
|
- **Do not edit `MEMORY.md` directly.** It is dreaming-owned. The default system prompt says this verbatim. If you write to `MEMORY.md` from a normal session, your edit will survive only until the next dreaming run, which rewrites the file from scratch using the consolidation logic above. The user's intent is almost never "diff-edit `MEMORY.md`" — see "When the user asks ..." below for the right routings.
|
|
89
|
-
- **Do not write to `memory/yyyy-MM-dd.
|
|
89
|
+
- **Do not write to `memory/yyyy-MM-dd.jsonl`.** Daily streams are memory-logger's territory. The runtime reads watermarks out of these files; a hand-edit in the wrong place silently corrupts the cursor. (`memory/` is gitignored at the agent level but force-committed by the dreaming snapshot — your hand-edit there will not look untracked, but it will still be a bug.)
|
|
90
90
|
- **Do not write to `memory/skills/<name>/SKILL.md`.** That is the _muscle memory_ layer, owned exclusively by the dreaming subagent. The `typeclaw-skills` skill says the same thing from the skills-system angle; this skill says it from the memory angle. If you want a hand-authored skill, put it in `.agents/skills/` instead.
|
|
91
|
-
- **Do not write to `memory/.dreaming-state.json`.** It is internal bookkeeping (per-day
|
|
91
|
+
- **Do not write to `memory/.dreaming-state.json`.** It is internal bookkeeping (per-day dreamed-id sets). On malformed input the plugin fails open with empty state, so a wrong edit causes one redundant re-consolidation, but it is still a sign you misunderstood the contract.
|
|
92
92
|
- **Do not promise the user that an `idleMs` or `dreaming.schedule` change took effect just because you edited `typeclaw.json`.** Both fields are **restart-required** — the plugin reads them once at boot, and `reload` does not re-run plugin factories. Tell the user to run `typeclaw restart` (host stage).
|
|
93
93
|
- **Do not invent fragments.** If you find yourself wanting to "seed" a memory by hand, that is a symptom of the previous rules — surface the fact in your reply (so the memory-logger captures it) instead of writing to memory yourself.
|
|
94
94
|
- **Do not echo `[truncated]` or `[MISSING]` markers back at the user as if they were part of remembered content.** They are runtime annotations.
|
|
@@ -96,13 +96,13 @@ If `MEMORY.md` is missing, the section shows `[MISSING] Expected at: <path>`. If
|
|
|
96
96
|
## When the user asks "what do you remember?"
|
|
97
97
|
|
|
98
98
|
1. Read `MEMORY.md`. Summarize at the topic level — do not dump the whole file unless asked. Cite specific topics by their level-2 headings.
|
|
99
|
-
2. If relevant to the current task, also read the undreamed-tail of recent `memory/yyyy-MM-dd.
|
|
99
|
+
2. If relevant to the current task, also read the undreamed-tail of recent `memory/yyyy-MM-dd.jsonl` files for fresh observations not yet consolidated. (Note: these are already in your prompt under `# Memory`, so usually you can just refer to them rather than re-reading.)
|
|
100
100
|
3. If `MEMORY.md` is `[MISSING]` or `[EMPTY]`, say so plainly. The first dreaming run creates the file; if dreaming has never fired (e.g. no `memory.dreaming.schedule` configured, or fewer than ~24 hours since hatching), there is genuinely nothing yet.
|
|
101
101
|
|
|
102
102
|
## When the user asks "do you remember X?"
|
|
103
103
|
|
|
104
104
|
1. Search `MEMORY.md` and recent daily streams for a fragment matching X.
|
|
105
|
-
2. If you find one: say what you found and cite the source (the topic heading from `MEMORY.md`, or the
|
|
105
|
+
2. If you find one: say what you found and cite the source (the topic heading from `MEMORY.md`, or the `memory/yyyy-MM-dd#<id>` citation from the daily stream).
|
|
106
106
|
3. If you do not find one: say so plainly. **Do not invent a memory** to be helpful. The honest answer is "no, that is not in my memory" — the user can then decide whether to repeat the context now (which the memory-logger will pick up) or skip it.
|
|
107
107
|
|
|
108
108
|
## When the user asks "forget X" / "remove X from your memory"
|
|
@@ -125,7 +125,7 @@ Stay concrete. Use this map:
|
|
|
125
125
|
| File / dir | What it is | Who writes it | Tracked in git |
|
|
126
126
|
| ------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
127
127
|
| `MEMORY.md` | Long-term memory, consolidated topics with fragment citations. | Dreaming subagent (rewrites in full on each run). | Yes (force-committed under `dream:` commits, skip-worktree). |
|
|
128
|
-
| `memory/yyyy-MM-dd.
|
|
128
|
+
| `memory/yyyy-MM-dd.jsonl` | Daily fragment streams. Append-only during the day. | Memory-logger subagent (one fragment ≈ one prompt completion). | Gitignored, but force-committed in the dreaming snapshot. |
|
|
129
129
|
| `memory/skills/<name>/SKILL.md` | Muscle-memory skills distilled from recurring procedures. | Dreaming subagent only. | Gitignored, force-committed in the dreaming snapshot. |
|
|
130
130
|
| `memory/.dreaming-state.json` | Per-day watermarks (line counts already consolidated). Plain JSON, fail-open. | Dreaming subagent. | Gitignored, force-committed in the dreaming snapshot. |
|
|
131
131
|
|
package/src/usage/aggregate.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { AssistantRow } from './scan'
|
|
1
|
+
import type { AssistantRow, OriginKind } from './scan'
|
|
2
|
+
import { ORIGIN_KINDS } from './scan'
|
|
2
3
|
|
|
3
4
|
export type UsageTotals = {
|
|
4
5
|
messageCount: number
|
|
@@ -18,13 +19,16 @@ export type SessionUsage = UsageTotals & {
|
|
|
18
19
|
firstAt: number
|
|
19
20
|
lastAt: number
|
|
20
21
|
models: string[]
|
|
22
|
+
originKind: OriginKind
|
|
21
23
|
}
|
|
24
|
+
export type OriginUsage = UsageTotals & { originKind: OriginKind; sessionCount: number }
|
|
22
25
|
|
|
23
26
|
export type Aggregation = {
|
|
24
27
|
total: UsageTotals
|
|
25
28
|
byDay: DailyUsage[]
|
|
26
29
|
byModel: ModelUsage[]
|
|
27
30
|
bySession: SessionUsage[]
|
|
31
|
+
byOrigin: OriginUsage[]
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggregation> {
|
|
@@ -32,6 +36,7 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
|
|
|
32
36
|
const byDay = new Map<string, DailyUsage & { _sessionIds: Set<string> }>()
|
|
33
37
|
const byModel = new Map<string, ModelUsage>()
|
|
34
38
|
const bySession = new Map<string, SessionUsage & { _modelSet: Set<string> }>()
|
|
39
|
+
const byOrigin = new Map<OriginKind, OriginUsage & { _sessionIds: Set<string> }>()
|
|
35
40
|
|
|
36
41
|
for await (const row of rows) {
|
|
37
42
|
addInto(total, row)
|
|
@@ -66,6 +71,7 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
|
|
|
66
71
|
lastAt: row.timestamp,
|
|
67
72
|
models: [],
|
|
68
73
|
_modelSet: new Set<string>(),
|
|
74
|
+
originKind: row.originKind,
|
|
69
75
|
}
|
|
70
76
|
addInto(sessionBucket, row)
|
|
71
77
|
sessionBucket.firstAt = Math.min(sessionBucket.firstAt, row.timestamp)
|
|
@@ -73,6 +79,17 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
|
|
|
73
79
|
sessionBucket._modelSet.add(modelKey)
|
|
74
80
|
sessionBucket.models = [...sessionBucket._modelSet]
|
|
75
81
|
bySession.set(sessionKey, sessionBucket)
|
|
82
|
+
|
|
83
|
+
const originBucket = byOrigin.get(row.originKind) ?? {
|
|
84
|
+
...emptyTotals(),
|
|
85
|
+
originKind: row.originKind,
|
|
86
|
+
sessionCount: 0,
|
|
87
|
+
_sessionIds: new Set<string>(),
|
|
88
|
+
}
|
|
89
|
+
addInto(originBucket, row)
|
|
90
|
+
originBucket._sessionIds.add(sessionKey)
|
|
91
|
+
originBucket.sessionCount = originBucket._sessionIds.size
|
|
92
|
+
byOrigin.set(row.originKind, originBucket)
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
return {
|
|
@@ -80,9 +97,21 @@ export async function aggregate(rows: AsyncIterable<AssistantRow>): Promise<Aggr
|
|
|
80
97
|
byDay: [...byDay.values()].map(({ _sessionIds: _, ...rest }) => rest).sort((a, b) => a.date.localeCompare(b.date)),
|
|
81
98
|
byModel: [...byModel.values()].sort((a, b) => b.cost - a.cost),
|
|
82
99
|
bySession: [...bySession.values()].map(({ _modelSet: _, ...rest }) => rest).sort((a, b) => b.cost - a.cost),
|
|
100
|
+
byOrigin: [...byOrigin.values()]
|
|
101
|
+
.map(({ _sessionIds: _, ...rest }) => rest)
|
|
102
|
+
.sort((a, b) => originSortIndex(a.originKind) - originSortIndex(b.originKind)),
|
|
83
103
|
}
|
|
84
104
|
}
|
|
85
105
|
|
|
106
|
+
// Stable presentation order for the byOrigin table. Matches ORIGIN_KINDS so
|
|
107
|
+
// the renderer doesn't need to know about ordering. 'unknown' is pinned last
|
|
108
|
+
// because it represents legacy/malformed data the user probably cares about
|
|
109
|
+
// least.
|
|
110
|
+
function originSortIndex(kind: OriginKind): number {
|
|
111
|
+
const idx = (ORIGIN_KINDS as readonly OriginKind[]).indexOf(kind)
|
|
112
|
+
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx
|
|
113
|
+
}
|
|
114
|
+
|
|
86
115
|
function emptyTotals(): UsageTotals {
|
|
87
116
|
return { messageCount: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0 }
|
|
88
117
|
}
|
package/src/usage/index.ts
CHANGED
|
@@ -4,8 +4,9 @@ import type { Aggregation } from './aggregate'
|
|
|
4
4
|
import { aggregate } from './aggregate'
|
|
5
5
|
import { scanAssistantRows } from './scan'
|
|
6
6
|
|
|
7
|
-
export type { Aggregation, DailyUsage, ModelUsage, SessionUsage, UsageTotals } from './aggregate'
|
|
8
|
-
export type { AssistantRow } from './scan'
|
|
7
|
+
export type { Aggregation, DailyUsage, ModelUsage, OriginUsage, SessionUsage, UsageTotals } from './aggregate'
|
|
8
|
+
export type { AssistantRow, OriginKind } from './scan'
|
|
9
|
+
export { ORIGIN_KINDS } from './scan'
|
|
9
10
|
|
|
10
11
|
export type UsageReport = {
|
|
11
12
|
generatedAt: number
|
package/src/usage/report.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { styleText } from 'node:util'
|
|
2
2
|
|
|
3
|
-
import type { ModelUsage, UsageTotals } from './aggregate'
|
|
3
|
+
import type { ModelUsage, OriginUsage, UsageTotals } from './aggregate'
|
|
4
4
|
import { formatCacheHitRate, formatCost, formatTokens, isoDay } from './format'
|
|
5
5
|
import type { UsageReport } from './index'
|
|
6
|
+
import type { OriginKind } from './scan'
|
|
6
7
|
|
|
7
8
|
export type FormatOptions = {
|
|
8
9
|
useColor?: boolean
|
|
9
|
-
view?: 'summary' | 'daily' | 'session' | 'models'
|
|
10
|
+
view?: 'summary' | 'daily' | 'session' | 'models' | 'origin'
|
|
10
11
|
limit?: number
|
|
11
12
|
// Terminal width hint used to size the elastic Item column. Omit to render
|
|
12
13
|
// without truncation (tests, piped output where columns is undefined).
|
|
@@ -26,6 +27,8 @@ export function formatReport(report: UsageReport, opts: FormatOptions = {}): str
|
|
|
26
27
|
return renderSessions(report, ctx, opts.limit ?? 20)
|
|
27
28
|
case 'models':
|
|
28
29
|
return renderModels(report, ctx, opts.limit)
|
|
30
|
+
case 'origin':
|
|
31
|
+
return renderOrigin(report, ctx)
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -58,12 +61,20 @@ function renderSummary(report: UsageReport, ctx: RenderCtx): string {
|
|
|
58
61
|
|
|
59
62
|
if (aggregation.byDay.length > 0) {
|
|
60
63
|
sections.push('')
|
|
64
|
+
const trend = renderDailyTrend(aggregation.byDay, ctx)
|
|
65
|
+
if (trend !== null) sections.push(trend, '')
|
|
61
66
|
sections.push(header('By day (most recent first)', ctx))
|
|
62
67
|
const recent = aggregation.byDay.slice(-7).reverse()
|
|
63
68
|
const dayRows = recent.map((d) => ({ label: d.date, totals: d as UsageTotals }))
|
|
64
69
|
sections.push(renderTotalsTable(dayRows, ctx, { total: totalOfRows(dayRows) }))
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
if (aggregation.byOrigin.length > 0) {
|
|
73
|
+
sections.push('')
|
|
74
|
+
sections.push(header('By origin', ctx))
|
|
75
|
+
sections.push(renderOriginTable(aggregation.byOrigin, ctx))
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
if (aggregation.byModel.length > 0) {
|
|
68
79
|
sections.push('')
|
|
69
80
|
sections.push(header('By model', ctx))
|
|
@@ -84,6 +95,79 @@ function renderSummary(report: UsageReport, ctx: RenderCtx): string {
|
|
|
84
95
|
return sections.join('\n')
|
|
85
96
|
}
|
|
86
97
|
|
|
98
|
+
function renderOrigin(report: UsageReport, ctx: RenderCtx): string {
|
|
99
|
+
const sections: string[] = [sectionTitle('USAGE BY ORIGIN', report.agentDir, ctx)]
|
|
100
|
+
if (report.aggregation.byOrigin.length === 0) {
|
|
101
|
+
sections.push(dim('No assistant turns recorded yet.', ctx))
|
|
102
|
+
return sections.join('\n')
|
|
103
|
+
}
|
|
104
|
+
sections.push(renderOriginTable(report.aggregation.byOrigin, ctx))
|
|
105
|
+
return sections.join('\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Single source of truth for the origin breakdown table used by both the
|
|
109
|
+
// summary view and the dedicated `usage origin` subcommand. Matches the
|
|
110
|
+
// column shape of renderTotalsTable's other callers (byDay, byModel) plus
|
|
111
|
+
// one extra column for session count — deliberately plain numbers rather
|
|
112
|
+
// than a proportional bar, since the Cost column already lets the eye sort
|
|
113
|
+
// rows by spend and an extra "share of max" bar adds no information that
|
|
114
|
+
// isn't already in the report.
|
|
115
|
+
function renderOriginTable(byOrigin: readonly OriginUsage[], ctx: RenderCtx): string {
|
|
116
|
+
const rows = byOrigin.map((o) => ({
|
|
117
|
+
label: renderOriginLabel(o.originKind, ctx),
|
|
118
|
+
totals: o as UsageTotals,
|
|
119
|
+
extra: String(o.sessionCount),
|
|
120
|
+
extraTruncatable: false,
|
|
121
|
+
}))
|
|
122
|
+
return renderTotalsTable(rows, ctx, {
|
|
123
|
+
extraHeader: 'Sessions',
|
|
124
|
+
total: totalOfRows(rows),
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Glyph + colored label for each origin kind. The glyphs are chosen to be
|
|
129
|
+
// instantly readable in a dense table: ▶ for TUI (interactive), ⏱ for cron
|
|
130
|
+
// (time-triggered), # for channel (chat-room sigil), ↳ for subagent (child),
|
|
131
|
+
// ? for unknown. ASCII fallbacks are not provided — the codebase already
|
|
132
|
+
// requires unicode for `…` `●` `✓` etc. on other commands.
|
|
133
|
+
function renderOriginLabel(kind: OriginKind, ctx: RenderCtx): string {
|
|
134
|
+
switch (kind) {
|
|
135
|
+
case 'tui':
|
|
136
|
+
return `${color('cyan', '▶', ctx)} ${'tui'}`
|
|
137
|
+
case 'cron':
|
|
138
|
+
return `${color('magenta', '⏱', ctx)} ${'cron'}`
|
|
139
|
+
case 'channel':
|
|
140
|
+
return `${color('green', '#', ctx)} ${'channel'}`
|
|
141
|
+
case 'subagent':
|
|
142
|
+
return `${color('yellow', '↳', ctx)} ${'subagent'}`
|
|
143
|
+
case 'unknown':
|
|
144
|
+
return `${dim('?', ctx)} ${dim('unknown', ctx)}`
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Sparkline trend across the full byDay range, scaled to the row's max cost.
|
|
149
|
+
// Returns null when there are fewer than 2 days (a single point conveys no
|
|
150
|
+
// trend information). The 8-level Unicode block scale `▁▂▃▄▅▆▇█` lets us
|
|
151
|
+
// pack ~80 days into a one-line glance — wider than any table-based view
|
|
152
|
+
// could fit at terminal widths under ~160 columns.
|
|
153
|
+
const SPARK_GLYPHS = '▁▂▃▄▅▆▇█'
|
|
154
|
+
|
|
155
|
+
function renderDailyTrend(byDay: readonly { date: string; cost: number }[], ctx: RenderCtx): string | null {
|
|
156
|
+
if (byDay.length < 2) return null
|
|
157
|
+
const costs = byDay.map((d) => d.cost)
|
|
158
|
+
const max = costs.reduce((m, c) => Math.max(m, c), 0)
|
|
159
|
+
if (max <= 0) return null
|
|
160
|
+
const spark = costs
|
|
161
|
+
.map((c) => {
|
|
162
|
+
const idx = Math.min(SPARK_GLYPHS.length - 1, Math.max(0, Math.round((c / max) * (SPARK_GLYPHS.length - 1))))
|
|
163
|
+
return SPARK_GLYPHS[idx]!
|
|
164
|
+
})
|
|
165
|
+
.join('')
|
|
166
|
+
const first = byDay[0]!.date
|
|
167
|
+
const last = byDay[byDay.length - 1]!.date
|
|
168
|
+
return `${dim('Trend (cost):', ctx)} ${color('cyan', spark, ctx)} ${dim(`${first} → ${last}`, ctx)}`
|
|
169
|
+
}
|
|
170
|
+
|
|
87
171
|
function renderDaily(report: UsageReport, ctx: RenderCtx, limit: number | undefined): string {
|
|
88
172
|
const days = limit !== undefined ? report.aggregation.byDay.slice(-limit) : report.aggregation.byDay
|
|
89
173
|
if (days.length === 0) return dim('No usage in range.', ctx)
|
|
@@ -100,8 +184,9 @@ function renderSessions(report: UsageReport, ctx: RenderCtx, limit: number): str
|
|
|
100
184
|
const rows = sessions.map((s) => {
|
|
101
185
|
const firstModel = s.models[0]
|
|
102
186
|
const extra = s.models.length > 1 ? `${s.models.length} models` : modelIdFromKey(firstModel)
|
|
187
|
+
const originGlyph = originGlyphOnly(s.originKind, ctx)
|
|
103
188
|
return {
|
|
104
|
-
label: `${color('magenta', s.sessionId.slice(0, 12), ctx)} ${dim(isoDay(s.firstAt), ctx)}`,
|
|
189
|
+
label: `${originGlyph} ${color('magenta', s.sessionId.slice(0, 12), ctx)} ${dim(isoDay(s.firstAt), ctx)}`,
|
|
105
190
|
totals: s as UsageTotals,
|
|
106
191
|
extra,
|
|
107
192
|
extraTruncatable: s.models.length === 1,
|
|
@@ -113,6 +198,21 @@ function renderSessions(report: UsageReport, ctx: RenderCtx, limit: number): str
|
|
|
113
198
|
].join('\n')
|
|
114
199
|
}
|
|
115
200
|
|
|
201
|
+
function originGlyphOnly(kind: OriginKind, ctx: RenderCtx): string {
|
|
202
|
+
switch (kind) {
|
|
203
|
+
case 'tui':
|
|
204
|
+
return color('cyan', '▶', ctx)
|
|
205
|
+
case 'cron':
|
|
206
|
+
return color('magenta', '⏱', ctx)
|
|
207
|
+
case 'channel':
|
|
208
|
+
return color('green', '#', ctx)
|
|
209
|
+
case 'subagent':
|
|
210
|
+
return color('yellow', '↳', ctx)
|
|
211
|
+
case 'unknown':
|
|
212
|
+
return dim('?', ctx)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
116
216
|
function renderModels(report: UsageReport, ctx: RenderCtx, limit: number | undefined): string {
|
|
117
217
|
const models = limit !== undefined ? report.aggregation.byModel.slice(0, limit) : report.aggregation.byModel
|
|
118
218
|
if (models.length === 0) return dim('No models in range.', ctx)
|
package/src/usage/scan.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
+
// Recognised origin kinds. Keep aligned with SessionOrigin's discriminator in
|
|
5
|
+
// src/agent/session-origin.ts. The 'unknown' bucket catches sessions written
|
|
6
|
+
// before origin stamping landed AND sessions whose session-meta line is
|
|
7
|
+
// malformed or missing — surfacing them under one explicit label is more
|
|
8
|
+
// honest than silently dropping them.
|
|
9
|
+
export const ORIGIN_KINDS = ['tui', 'cron', 'channel', 'subagent', 'unknown'] as const
|
|
10
|
+
export type OriginKind = (typeof ORIGIN_KINDS)[number]
|
|
11
|
+
|
|
4
12
|
// Narrow projection: session files can grow into tens of MB on long-lived
|
|
5
13
|
// agents, so we deliberately drop content/tool blocks before aggregation.
|
|
6
14
|
export type AssistantRow = {
|
|
@@ -15,6 +23,7 @@ export type AssistantRow = {
|
|
|
15
23
|
cacheWrite: number
|
|
16
24
|
totalTokens: number
|
|
17
25
|
cost: number
|
|
26
|
+
originKind: OriginKind
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
export type ScanOptions = {
|
|
@@ -66,6 +75,13 @@ async function* readSessionFile(file: string, opts: ScanOptions): AsyncGenerator
|
|
|
66
75
|
}
|
|
67
76
|
const decoder = new TextDecoder()
|
|
68
77
|
let buf = ''
|
|
78
|
+
// First-stamp-wins per file. Once a `typeclaw.session-meta` custom entry
|
|
79
|
+
// pins the origin, later entries with the same customType are ignored —
|
|
80
|
+
// session-resume code paths may legitimately re-stamp on reopen, and the
|
|
81
|
+
// earliest one is the authoritative one for the session's first turn.
|
|
82
|
+
// Stays 'unknown' for legacy files (no stamp at all) so usage attribution
|
|
83
|
+
// surfaces them as a distinct bucket rather than dropping the rows.
|
|
84
|
+
const ctx: ParseCtx = { originKind: 'unknown', originPinned: false }
|
|
69
85
|
try {
|
|
70
86
|
for await (const chunk of stream) {
|
|
71
87
|
buf += decoder.decode(chunk, { stream: true })
|
|
@@ -73,7 +89,7 @@ async function* readSessionFile(file: string, opts: ScanOptions): AsyncGenerator
|
|
|
73
89
|
while (nl !== -1) {
|
|
74
90
|
const line = buf.slice(0, nl)
|
|
75
91
|
buf = buf.slice(nl + 1)
|
|
76
|
-
const row = parseLine(line, file, basename, opts)
|
|
92
|
+
const row = parseLine(line, file, basename, opts, ctx)
|
|
77
93
|
if (row !== null) yield row
|
|
78
94
|
nl = buf.indexOf('\n')
|
|
79
95
|
}
|
|
@@ -86,17 +102,20 @@ async function* readSessionFile(file: string, opts: ScanOptions): AsyncGenerator
|
|
|
86
102
|
// half-written record from a live writer is silently skipped (parseLine
|
|
87
103
|
// returns null and does NOT warn for the tail).
|
|
88
104
|
if (buf.length > 0) {
|
|
89
|
-
const row = parseLine(buf, file, basename, opts, { isTail: true })
|
|
105
|
+
const row = parseLine(buf, file, basename, opts, ctx, { isTail: true })
|
|
90
106
|
if (row !== null) yield row
|
|
91
107
|
}
|
|
92
108
|
}
|
|
93
109
|
|
|
110
|
+
type ParseCtx = { originKind: OriginKind; originPinned: boolean }
|
|
111
|
+
|
|
94
112
|
function parseLine(
|
|
95
113
|
line: string,
|
|
96
114
|
file: string,
|
|
97
115
|
basename: string,
|
|
98
116
|
opts: ScanOptions,
|
|
99
|
-
ctx:
|
|
117
|
+
ctx: ParseCtx,
|
|
118
|
+
flags: { isTail?: boolean } = {},
|
|
100
119
|
): AssistantRow | null {
|
|
101
120
|
const trimmed = line.trim()
|
|
102
121
|
if (trimmed === '') return null
|
|
@@ -106,7 +125,18 @@ function parseLine(
|
|
|
106
125
|
entry = JSON.parse(trimmed)
|
|
107
126
|
} catch {
|
|
108
127
|
// Silently skip the trailing tail: a live writer may be mid-append.
|
|
109
|
-
if (
|
|
128
|
+
if (flags.isTail !== true) opts.onWarn?.(`skipping malformed JSONL line in ${basename}`)
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isSessionMetaCustomEntry(entry)) {
|
|
133
|
+
if (!ctx.originPinned) {
|
|
134
|
+
const kind = entry.data.origin.kind
|
|
135
|
+
if ((ORIGIN_KINDS as readonly string[]).includes(kind)) {
|
|
136
|
+
ctx.originKind = kind as OriginKind
|
|
137
|
+
ctx.originPinned = true
|
|
138
|
+
}
|
|
139
|
+
}
|
|
110
140
|
return null
|
|
111
141
|
}
|
|
112
142
|
|
|
@@ -134,9 +164,34 @@ function parseLine(
|
|
|
134
164
|
cacheWrite: numberOrZero(u.cacheWrite),
|
|
135
165
|
totalTokens: numberOrZero(u.totalTokens),
|
|
136
166
|
cost: numberOrZero(u.cost?.total),
|
|
167
|
+
originKind: ctx.originKind,
|
|
137
168
|
}
|
|
138
169
|
}
|
|
139
170
|
|
|
171
|
+
// Pi-coding-agent persists `appendCustomEntry(customType, data)` calls as
|
|
172
|
+
// `{type:"custom", customType, data, id, parentId, timestamp}` lines. We
|
|
173
|
+
// stamp our origin block with customType `typeclaw.session-meta` (constant
|
|
174
|
+
// kept in src/agent/session-meta.ts; duplicated as a literal here to keep
|
|
175
|
+
// the usage subsystem free of agent-stack imports — a Grep across the repo
|
|
176
|
+
// is the chosen drift guard).
|
|
177
|
+
type SessionMetaCustomEntry = {
|
|
178
|
+
type: 'custom'
|
|
179
|
+
customType: 'typeclaw.session-meta'
|
|
180
|
+
data: { origin: { kind: string } }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isSessionMetaCustomEntry(value: unknown): value is SessionMetaCustomEntry {
|
|
184
|
+
if (typeof value !== 'object' || value === null) return false
|
|
185
|
+
const v = value as Record<string, unknown>
|
|
186
|
+
if (v.type !== 'custom') return false
|
|
187
|
+
if (v.customType !== 'typeclaw.session-meta') return false
|
|
188
|
+
if (typeof v.data !== 'object' || v.data === null) return false
|
|
189
|
+
const d = v.data as Record<string, unknown>
|
|
190
|
+
if (typeof d.origin !== 'object' || d.origin === null) return false
|
|
191
|
+
const o = d.origin as Record<string, unknown>
|
|
192
|
+
return typeof o.kind === 'string'
|
|
193
|
+
}
|
|
194
|
+
|
|
140
195
|
type MessageEntry = { type: 'message'; message: { role: string; [k: string]: unknown } }
|
|
141
196
|
type AssistantMessageShape = {
|
|
142
197
|
role: 'assistant'
|