talon-agent 4.4.0 → 4.6.0
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 +1 -1
- package/prompts/system/memory-core-view.md +7 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/memory.ts +31 -0
- package/src/core/memory/core-view.ts +158 -0
- package/src/core/memory/flag.ts +16 -0
- package/src/core/memory/import.ts +380 -0
- package/src/core/memory/render.ts +243 -0
- package/src/core/prompt/assemble.ts +69 -16
- package/src/core/prompt/embedded-prompts.ts +20 -18
- package/src/core/prompt/memory-view.ts +8 -8
- package/src/frontend/native/handlers.ts +5 -0
- package/src/frontend/native/memory.ts +112 -0
- package/src/frontend/native/protocol.ts +45 -0
- package/src/frontend/native/routes/host.ts +9 -0
- package/src/frontend/native/routes/index.ts +2 -0
- package/src/frontend/native/routes/memory.ts +34 -0
- package/src/frontend/native/routes/table.ts +7 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +3 -0
- package/src/frontend/telegram/commands/info.ts +3 -0
- package/src/frontend/telegram/commands/memory.ts +112 -0
- package/src/storage/memory.ts +8 -0
- package/src/storage/metrics.ts +26 -1
package/package.json
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
## Persistent Memory
|
|
2
|
+
|
|
3
|
+
The following is your memory store's core view — the durable claims that matter most: pinned items, standing directives, who you are talking to, your highest-salience facts, and current status. It is selected and budgeted, not a whole file, so anything not here is not gone. The rest of the store is one query away (`talon memory list`, or `/memory` in chat), and the full rendered projection is at ~/.talon/workspace/memory/memory.md.
|
|
4
|
+
|
|
5
|
+
Durable facts, not live status. Reference it naturally; the Memory and Recall policy in this prompt governs how you add to it and keep it current.
|
|
6
|
+
|
|
7
|
+
{{content}}
|
package/src/cli/index.ts
CHANGED
|
@@ -157,7 +157,7 @@ export async function runCli(): Promise<void> {
|
|
|
157
157
|
` ${pc.cyan("skill")} Manage skills (install/enable/disable)`,
|
|
158
158
|
);
|
|
159
159
|
console.log(
|
|
160
|
-
` ${pc.cyan("memory")} Read/edit the memory store (list/search/
|
|
160
|
+
` ${pc.cyan("memory")} Read/edit the memory store (list/search/import/render)`,
|
|
161
161
|
);
|
|
162
162
|
console.log(` ${pc.cyan("config")} View/edit configuration`);
|
|
163
163
|
console.log(` ${pc.cyan("logs")} Tail log file`);
|
package/src/cli/memory.ts
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import pc from "picocolors";
|
|
11
|
+
import { importAll } from "../core/memory/import.js";
|
|
12
|
+
import {
|
|
13
|
+
renderMemoryMarkdown,
|
|
14
|
+
writeRenderedMemory,
|
|
15
|
+
} from "../core/memory/render.js";
|
|
11
16
|
import {
|
|
12
17
|
assertMemory,
|
|
13
18
|
dropMemory,
|
|
@@ -33,6 +38,8 @@ const USAGE = [
|
|
|
33
38
|
` ${pc.cyan("remember <kind> <subject> <text>")} Record an operator claim`,
|
|
34
39
|
` ${pc.cyan("forget <id> [reason]")} Drop a row to the graveyard`,
|
|
35
40
|
` ${pc.cyan("state <key> <text>")} Replace the row for a state key`,
|
|
41
|
+
` ${pc.cyan("import")} Fold memory.md + daily notes into the store`,
|
|
42
|
+
` ${pc.cyan("render [--write]")} Render the store as memory.md`,
|
|
36
43
|
"",
|
|
37
44
|
` Kinds: ${MEMORY_KINDS.join(", ")}`,
|
|
38
45
|
"",
|
|
@@ -134,6 +141,24 @@ function cmdState(args: readonly string[]): void {
|
|
|
134
141
|
console.log(` ${pc.green("●")} ${key} is now #${id}\n`);
|
|
135
142
|
}
|
|
136
143
|
|
|
144
|
+
function cmdImport(): void {
|
|
145
|
+
const { inserted, superseded, skipped } = importAll();
|
|
146
|
+
console.log(
|
|
147
|
+
` ${pc.green("●")} Imported: ${inserted} new, ${superseded} updated, ${skipped} unchanged\n`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function cmdRender(args: readonly string[]): void {
|
|
152
|
+
if (!args.includes("--write")) {
|
|
153
|
+
console.log(renderMemoryMarkdown());
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const { path, bytes, backup } = writeRenderedMemory();
|
|
157
|
+
console.log(` ${pc.green("●")} Wrote ${bytes} chars to ${path}`);
|
|
158
|
+
if (backup) console.log(` ${pc.dim(`Previous file archived to ${backup}`)}`);
|
|
159
|
+
console.log();
|
|
160
|
+
}
|
|
161
|
+
|
|
137
162
|
/** Route a `talon memory <command>` invocation. */
|
|
138
163
|
export function runMemoryCommand(args: readonly string[]): void {
|
|
139
164
|
try {
|
|
@@ -157,6 +182,12 @@ export function runMemoryCommand(args: readonly string[]): void {
|
|
|
157
182
|
case "state":
|
|
158
183
|
cmdState(args.slice(1));
|
|
159
184
|
break;
|
|
185
|
+
case "import":
|
|
186
|
+
cmdImport();
|
|
187
|
+
break;
|
|
188
|
+
case "render":
|
|
189
|
+
cmdRender(args.slice(1));
|
|
190
|
+
break;
|
|
160
191
|
default:
|
|
161
192
|
console.log(USAGE);
|
|
162
193
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The **core view** — the store's contribution to the static system
|
|
3
|
+
* prompt (docs/memory-persona-plan.md §3.4).
|
|
4
|
+
*
|
|
5
|
+
* Retrieval has two tiers, and the boundary between them is a
|
|
6
|
+
* prompt-cache invariant:
|
|
7
|
+
*
|
|
8
|
+
* - **Core view** (this module) — pinned directives, the relationship
|
|
9
|
+
* layer and the top durable facts plus fresh `state`, computed
|
|
10
|
+
* **once per session build** and living in `staticText`. Budgeted,
|
|
11
|
+
* never truncated mid-section.
|
|
12
|
+
* - **Turn retrieval** (PR 8) — keyed to the incoming message and
|
|
13
|
+
* injected into the *user turn*, never into `prepareSystemPrompt()`.
|
|
14
|
+
*
|
|
15
|
+
* Because `prepareSystemPrompt` freezes the assembled prompt per
|
|
16
|
+
* `(chatId, sessionEpoch)`, a fact learned at turn 3 cannot appear in
|
|
17
|
+
* that session's core view. That is the design, not a bug: reaching for
|
|
18
|
+
* `notifyPromptInputsChanged()` to "fix" it would force a full-prompt
|
|
19
|
+
* cache write across every live session on every learned fact (plan
|
|
20
|
+
* §3.6). Nothing here may ever invalidate a snapshot.
|
|
21
|
+
*
|
|
22
|
+
* The store is consulted exactly once per build — one `listMemories`
|
|
23
|
+
* call, ranked and filtered in memory — so the cost of the core view is
|
|
24
|
+
* one query per session, not one per kind and not one per turn.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { listMemories, type MemoryRow } from "../../storage/memory.js";
|
|
28
|
+
import { renderMemoryMarkdown } from "./render.js";
|
|
29
|
+
|
|
30
|
+
// ── Tunables ────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Char budget for the whole core view (~2 k tokens). Deliberately well
|
|
34
|
+
* under the 12 k file-injection cap: this block is paid for by every
|
|
35
|
+
* session on every frontend, and the rest of the store is a `talon
|
|
36
|
+
* memory list` away.
|
|
37
|
+
*/
|
|
38
|
+
export const CORE_VIEW_MAX_CHARS = 8_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A `state` row older than this is stale and left out — a status
|
|
42
|
+
* snapshot nobody has touched in a week is noise in a prompt, and the
|
|
43
|
+
* heartbeat rewrites the live ones far more often than that. Pinning
|
|
44
|
+
* overrides it, which is what pinning is for.
|
|
45
|
+
*/
|
|
46
|
+
const STATE_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rows pulled from the store before ranking. Generous: the budget, not
|
|
50
|
+
* this number, is what decides how much reaches the prompt.
|
|
51
|
+
*/
|
|
52
|
+
const CORE_VIEW_ROW_LIMIT = 500;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Kinds eligible for the core view, most durable first. `episode` and
|
|
56
|
+
* `reflection` are absent on purpose (plan §3.1): episodes decay fast
|
|
57
|
+
* and are a retrieval-only source, and the diary is the persona layer
|
|
58
|
+
* and never a fact source. Neither enters the core view — not even
|
|
59
|
+
* pinned, since a pin must not promote a kind past the tier boundary.
|
|
60
|
+
*/
|
|
61
|
+
const CORE_KINDS: readonly MemoryRow["kind"][] = [
|
|
62
|
+
"directive",
|
|
63
|
+
"relationship",
|
|
64
|
+
"fact",
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// ── Selection ───────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** Salience first, then recency; id breaks the tie so the order is stable. */
|
|
70
|
+
function byRank(a: MemoryRow, b: MemoryRow): number {
|
|
71
|
+
return b.salience - a.salience || b.lastSeenAt - a.lastSeenAt || a.id - b.id;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function eligible(row: MemoryRow, now: number): boolean {
|
|
75
|
+
if (row.kind === "state")
|
|
76
|
+
return row.pinned || now - row.lastSeenAt <= STATE_FRESH_MS;
|
|
77
|
+
return CORE_KINDS.includes(row.kind);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Take rows until the budget is spent. A coarse pre-cut on row text
|
|
82
|
+
* only: the render does the exact accounting and cuts on section
|
|
83
|
+
* boundaries, so this exists to keep a 500-row store from being ranked
|
|
84
|
+
* into a document that can hold a fraction of it.
|
|
85
|
+
*/
|
|
86
|
+
function withinBudget(rows: readonly MemoryRow[], budget: number): MemoryRow[] {
|
|
87
|
+
const taken: MemoryRow[] = [];
|
|
88
|
+
let spent = 0;
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
if (spent > budget) break;
|
|
91
|
+
spent += row.text.length;
|
|
92
|
+
taken.push(row);
|
|
93
|
+
}
|
|
94
|
+
return taken;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Options shared by the selection and the render. */
|
|
98
|
+
export type CoreViewOptions = {
|
|
99
|
+
/** Char budget for the rendered view. Defaults to `CORE_VIEW_MAX_CHARS`. */
|
|
100
|
+
budget?: number;
|
|
101
|
+
/** Clock for the `state` freshness window (tests pin it). */
|
|
102
|
+
now?: number;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The ordered rows behind the core view: pinned first (any eligible
|
|
107
|
+
* kind), then directives, then the relationship layer, then facts by
|
|
108
|
+
* salience, then fresh `state`.
|
|
109
|
+
*
|
|
110
|
+
* The render re-sorts with the same ranking (`render.ts` `compareRows`),
|
|
111
|
+
* so this order is what reaches the prompt rather than merely what is
|
|
112
|
+
* handed over — but the selection is asserted here, where the tiers are
|
|
113
|
+
* decided, and not through the rendered bytes.
|
|
114
|
+
*/
|
|
115
|
+
export function selectCoreRows(opts: CoreViewOptions = {}): MemoryRow[] {
|
|
116
|
+
const now = opts.now ?? Date.now();
|
|
117
|
+
const live = listMemories({ limit: CORE_VIEW_ROW_LIMIT }).filter((row) =>
|
|
118
|
+
eligible(row, now),
|
|
119
|
+
);
|
|
120
|
+
const tiers: MemoryRow[][] = [
|
|
121
|
+
live.filter((row) => row.pinned).sort(byRank),
|
|
122
|
+
...CORE_KINDS.map((kind) =>
|
|
123
|
+
live.filter((row) => !row.pinned && row.kind === kind).sort(byRank),
|
|
124
|
+
),
|
|
125
|
+
live.filter((row) => !row.pinned && row.kind === "state").sort(byRank),
|
|
126
|
+
];
|
|
127
|
+
const seen = new Set<number>();
|
|
128
|
+
const ordered = tiers.flat().filter((row) => {
|
|
129
|
+
if (seen.has(row.id)) return false;
|
|
130
|
+
seen.add(row.id);
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
return withinBudget(ordered, opts.budget ?? CORE_VIEW_MAX_CHARS);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** A rendered core view, with what it cost to carry. */
|
|
139
|
+
export type CoreView = {
|
|
140
|
+
text: string;
|
|
141
|
+
/** Rows selected into the view (0 means the store had nothing to say). */
|
|
142
|
+
rows: number;
|
|
143
|
+
/** Rendered size — the number recorded as `prompt.memory_chars`. */
|
|
144
|
+
chars: number;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render the core view as a `memory.md`-shaped document, reusing the
|
|
149
|
+
* projection renderer so the store reads identically in the prompt and
|
|
150
|
+
* in the file. Budgeted, not truncated: sections are dropped whole from
|
|
151
|
+
* the bottom of the ranking and the tail is named.
|
|
152
|
+
*/
|
|
153
|
+
export function renderCoreView(opts: CoreViewOptions = {}): CoreView {
|
|
154
|
+
const budget = opts.budget ?? CORE_VIEW_MAX_CHARS;
|
|
155
|
+
const rows = selectCoreRows({ ...opts, budget });
|
|
156
|
+
const text = renderMemoryMarkdown({ rows, budget });
|
|
157
|
+
return { text, rows: rows.length, chars: text.length };
|
|
158
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one flag that gates the typed memory store's read path
|
|
3
|
+
* (docs/memory-persona-rollout.md, "One flag gates the whole new path").
|
|
4
|
+
*
|
|
5
|
+
* Kept in its own module because `core/prompt/assemble.ts` asks the
|
|
6
|
+
* question but must not reach into `storage/` to answer it: the prompt
|
|
7
|
+
* layer sees the flag and the core view, nothing else.
|
|
8
|
+
*
|
|
9
|
+
* Read per call, never cached: tests flip it between builds, and the
|
|
10
|
+
* cost is an env lookup on a path that already reads files from disk.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** True when `TALON_MEMORY_STORE=1`. Off by default until PR 9 is measured. */
|
|
14
|
+
export function memoryStoreEnabled(): boolean {
|
|
15
|
+
return process.env.TALON_MEMORY_STORE === "1";
|
|
16
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `memory.md` and the daily notes → typed memory rows.
|
|
3
|
+
*
|
|
4
|
+
* The store (storage/memory.ts) becomes the source of truth in PR 8, but
|
|
5
|
+
* the markdown files hold years of the operator's own writing and stay
|
|
6
|
+
* hand-editable afterwards. This module is the bridge: it seeds the store
|
|
7
|
+
* from the files, and — because it is idempotent by content hash — it is
|
|
8
|
+
* also how a hand-edit to the rendered `memory.md` folds back in
|
|
9
|
+
* (plan §3.1, rollout PR 5).
|
|
10
|
+
*
|
|
11
|
+
* Three rules make re-running it safe:
|
|
12
|
+
*
|
|
13
|
+
* - **The parser is PR 1's.** Sections, families and tiers come from
|
|
14
|
+
* prompt/memory-view.ts, so what the prompt considers one status
|
|
15
|
+
* family is what the store considers one keyed state row. Nothing is
|
|
16
|
+
* classified twice, two different ways.
|
|
17
|
+
* - **Content hash decides.** A live import-owned row with the same
|
|
18
|
+
* kind + subject + key and the same hash is left alone; a different
|
|
19
|
+
* hash is a hand-edit, and supersedes the row so the old text stays
|
|
20
|
+
* in the audit trail. Only a subject never seen before inserts.
|
|
21
|
+
* - **The files are never written.** Import reads; render writes. A
|
|
22
|
+
* failed or partial import loses nothing.
|
|
23
|
+
*
|
|
24
|
+
* Kind follows the heading's tier, because the tier already encodes the
|
|
25
|
+
* lifecycle: directives are intent, status snapshots are keyed state,
|
|
26
|
+
* historical sections are episodes, people sections are relationships,
|
|
27
|
+
* everything else is a durable fact.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
31
|
+
import { basename, join } from "node:path";
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
MAX_SUBJECT_LENGTH,
|
|
35
|
+
MAX_TEXT_LENGTH,
|
|
36
|
+
assertMemory,
|
|
37
|
+
listMemories,
|
|
38
|
+
memoryContentHash,
|
|
39
|
+
replaceStateKey,
|
|
40
|
+
supersedeMemory,
|
|
41
|
+
type MemoryKind,
|
|
42
|
+
type MemoryRow,
|
|
43
|
+
type MemorySource,
|
|
44
|
+
} from "../../storage/memory.js";
|
|
45
|
+
import { dirs, files } from "../../util/paths.js";
|
|
46
|
+
import {
|
|
47
|
+
TIER_ORDER,
|
|
48
|
+
classify,
|
|
49
|
+
collapseFamilies,
|
|
50
|
+
familyKey,
|
|
51
|
+
parseSections,
|
|
52
|
+
type Section,
|
|
53
|
+
type Tier,
|
|
54
|
+
} from "../prompt/memory-view.js";
|
|
55
|
+
|
|
56
|
+
// ── Tunables ────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** `source.actor` on every row this module writes — the ownership marker. */
|
|
59
|
+
const IMPORT_ACTOR = "import";
|
|
60
|
+
|
|
61
|
+
/** Recorded on the supersede that a changed section causes. */
|
|
62
|
+
const RE_IMPORT_REASON = "re-import: file changed";
|
|
63
|
+
|
|
64
|
+
/** Daily notes only: `YYYY-MM-DD.md`, which excludes `diary-*.md`. */
|
|
65
|
+
const DAILY_NOTE = /^(\d{4}-\d{2}-\d{2})\.md$/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A heading that is just a date is a day's episode — which is how the
|
|
69
|
+
* render emits an imported daily note, so reading one back produces the
|
|
70
|
+
* episode it came from rather than a fresh fact.
|
|
71
|
+
*/
|
|
72
|
+
const DATE_HEADING = /^\d{4}-\d{2}-\d{2}$/;
|
|
73
|
+
|
|
74
|
+
/** How many same-subject rows are scanned for the import-owned one. */
|
|
75
|
+
const LOOKUP_LIMIT = 200;
|
|
76
|
+
|
|
77
|
+
/** Room left for a ` (n/N)` chunk or ` #n` duplicate suffix on a subject. */
|
|
78
|
+
const SUBJECT_SUFFIX_ROOM = 16;
|
|
79
|
+
|
|
80
|
+
/** Cap on a generated state key, inside the store's own 100-char limit. */
|
|
81
|
+
const MAX_SLUG_LENGTH = 90;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The lifecycle each heading tier maps to. `active` and `general` are
|
|
85
|
+
* durable knowledge with no special lifecycle, so both land as facts.
|
|
86
|
+
*/
|
|
87
|
+
const KIND_BY_TIER: Readonly<Record<Tier, MemoryKind>> = {
|
|
88
|
+
directive: "directive",
|
|
89
|
+
people: "relationship",
|
|
90
|
+
active: "fact",
|
|
91
|
+
general: "fact",
|
|
92
|
+
status: "state",
|
|
93
|
+
historical: "episode",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/** What one import run did, per row. */
|
|
99
|
+
export type ImportCounts = {
|
|
100
|
+
inserted: number;
|
|
101
|
+
superseded: number;
|
|
102
|
+
skipped: number;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** One row an import wants to land — the unit of the idempotency check. */
|
|
106
|
+
type Claim = {
|
|
107
|
+
kind: MemoryKind;
|
|
108
|
+
subject: string;
|
|
109
|
+
key?: string;
|
|
110
|
+
text: string;
|
|
111
|
+
source: MemorySource;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const emptyCounts = (): ImportCounts => ({
|
|
115
|
+
inserted: 0,
|
|
116
|
+
superseded: 0,
|
|
117
|
+
skipped: 0,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function addCounts(into: ImportCounts, from: ImportCounts): ImportCounts {
|
|
121
|
+
return {
|
|
122
|
+
inserted: into.inserted + from.inserted,
|
|
123
|
+
superseded: into.superseded + from.superseded,
|
|
124
|
+
skipped: into.skipped + from.skipped,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Text shaping ────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A state key from a family key: `Inbox / CI Watch` → `inbox.ci-watch`.
|
|
132
|
+
* The slash is the family separator the operator already writes, so it
|
|
133
|
+
* becomes the key's dot; everything else collapses to a dash.
|
|
134
|
+
*/
|
|
135
|
+
function slugKey(family: string): string {
|
|
136
|
+
const slug = family
|
|
137
|
+
.toLowerCase()
|
|
138
|
+
.replace(/\s*\/\s*/g, ".")
|
|
139
|
+
.replace(/\s+/g, "-")
|
|
140
|
+
.replace(/[^a-z0-9_.-]/g, "")
|
|
141
|
+
.replace(/-{2,}/g, "-")
|
|
142
|
+
.replace(/\.{2,}/g, ".")
|
|
143
|
+
.replace(/^[-._]+|[-._]+$/g, "");
|
|
144
|
+
return slug.slice(0, MAX_SLUG_LENGTH) || "section";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Demote `## ` headings inside a claim's body to `### `.
|
|
149
|
+
*
|
|
150
|
+
* A daily note is a whole document and routinely carries its own `## `
|
|
151
|
+
* headings. Stored verbatim, the render would emit them at the top level
|
|
152
|
+
* and the next import would read one row back as several sections — and
|
|
153
|
+
* an embedded `## Active Investigations` would collide with the real
|
|
154
|
+
* section of that name and supersede it. A claim's body therefore never
|
|
155
|
+
* holds a top-level heading. Running this twice changes nothing, which is
|
|
156
|
+
* what keeps the round trip a fixed point.
|
|
157
|
+
*/
|
|
158
|
+
function demoteHeadings(body: string): string {
|
|
159
|
+
return body.replace(/^##(?=\s)/gm, "###");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Blank-line-separated paragraphs, trimmed and normalized. */
|
|
163
|
+
function paragraphsOf(text: string): string[] {
|
|
164
|
+
return demoteHeadings(text)
|
|
165
|
+
.split(/\n\s*\n/)
|
|
166
|
+
.map((para) => para.trim())
|
|
167
|
+
.filter(Boolean);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A single paragraph bigger than the cap — the one place text is lost. */
|
|
171
|
+
function hardCut(para: string): string {
|
|
172
|
+
return `${para.slice(0, MAX_TEXT_LENGTH - 1).trimEnd()}…`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Pack paragraphs into chunks of at most `MAX_TEXT_LENGTH`. Splitting on
|
|
177
|
+
* paragraph boundaries keeps every chunk readable on its own — a claim
|
|
178
|
+
* cut mid-sentence is worse than no claim. Rejoining the chunks with a
|
|
179
|
+
* blank line reproduces the input exactly, which is what makes
|
|
180
|
+
* import → render → import a fixed point.
|
|
181
|
+
*/
|
|
182
|
+
function chunkParagraphs(paras: readonly string[]): string[] {
|
|
183
|
+
const chunks: string[] = [];
|
|
184
|
+
let current = "";
|
|
185
|
+
for (const para of paras) {
|
|
186
|
+
const piece = para.length > MAX_TEXT_LENGTH ? hardCut(para) : para;
|
|
187
|
+
const joined = current ? `${current}\n\n${piece}` : piece;
|
|
188
|
+
if (joined.length <= MAX_TEXT_LENGTH) {
|
|
189
|
+
current = joined;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (current) chunks.push(current);
|
|
193
|
+
current = piece;
|
|
194
|
+
}
|
|
195
|
+
if (current) chunks.push(current);
|
|
196
|
+
return chunks;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Claims ──────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Clamp a subject, leaving room for the suffixes appended below. */
|
|
202
|
+
function clampSubject(subject: string): string {
|
|
203
|
+
return subject.slice(0, MAX_SUBJECT_LENGTH - SUBJECT_SUFFIX_ROOM).trim();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Two sections can share a heading. Without a disambiguator the second
|
|
208
|
+
* would supersede the first on every run and vice versa, so occurrence
|
|
209
|
+
* `n > 1` gets a ` #n` marker — deliberately not a parenthetical, which
|
|
210
|
+
* `familyKey` would strip straight back off on the next import.
|
|
211
|
+
*/
|
|
212
|
+
function disambiguate(
|
|
213
|
+
seen: Map<string, number>,
|
|
214
|
+
kind: MemoryKind,
|
|
215
|
+
subject: string,
|
|
216
|
+
): string {
|
|
217
|
+
const slot = `${kind}|${subject}`;
|
|
218
|
+
const occurrence = (seen.get(slot) ?? 0) + 1;
|
|
219
|
+
seen.set(slot, occurrence);
|
|
220
|
+
return occurrence === 1 ? subject : `${subject} #${occurrence}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Spread one section's text across as many rows as the cap needs. */
|
|
224
|
+
function claimsFrom(
|
|
225
|
+
base: { kind: MemoryKind; subject: string; key?: string },
|
|
226
|
+
paras: readonly string[],
|
|
227
|
+
source: MemorySource,
|
|
228
|
+
): Claim[] {
|
|
229
|
+
const chunks = chunkParagraphs(paras);
|
|
230
|
+
const many = chunks.length > 1;
|
|
231
|
+
return chunks.map((text, index) => {
|
|
232
|
+
const nth = index + 1;
|
|
233
|
+
const subject = many
|
|
234
|
+
? `${base.subject} (${nth}/${chunks.length})`
|
|
235
|
+
: base.subject;
|
|
236
|
+
const key =
|
|
237
|
+
base.key !== undefined && many && nth > 1
|
|
238
|
+
? `${base.key}.${nth}`
|
|
239
|
+
: base.key;
|
|
240
|
+
return {
|
|
241
|
+
kind: base.kind,
|
|
242
|
+
subject,
|
|
243
|
+
text,
|
|
244
|
+
source,
|
|
245
|
+
...(key !== undefined ? { key } : {}),
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* One `## ` section → its rows. A `state` section's subject is its key:
|
|
252
|
+
* the render emits keyed rows under their key, so anchoring both on the
|
|
253
|
+
* slug is what keeps import → render → import a no-op.
|
|
254
|
+
*/
|
|
255
|
+
function sectionClaims(
|
|
256
|
+
section: Section,
|
|
257
|
+
source: MemorySource,
|
|
258
|
+
seen: Map<string, number>,
|
|
259
|
+
): Claim[] {
|
|
260
|
+
const dated = DATE_HEADING.test(section.title);
|
|
261
|
+
const tier = TIER_ORDER[classify(section.title)] ?? "general";
|
|
262
|
+
const kind = dated ? "episode" : KIND_BY_TIER[tier];
|
|
263
|
+
const family = familyKey(section.title) || section.title;
|
|
264
|
+
// People sections are per-person and a dated section is one day, so the
|
|
265
|
+
// heading itself is the subject; everything else is keyed on the family
|
|
266
|
+
// so a family's snapshots line up on one row.
|
|
267
|
+
const named = dated || tier === "people" ? section.title : family;
|
|
268
|
+
const key = kind === "state" ? slugKey(family) : undefined;
|
|
269
|
+
const subject = disambiguate(
|
|
270
|
+
seen,
|
|
271
|
+
kind,
|
|
272
|
+
clampSubject(key ?? named) || "untitled",
|
|
273
|
+
);
|
|
274
|
+
const newline = section.body.indexOf("\n");
|
|
275
|
+
const paras = paragraphsOf(
|
|
276
|
+
newline === -1 ? "" : section.body.slice(newline + 1),
|
|
277
|
+
);
|
|
278
|
+
if (paras.length === 0) return [];
|
|
279
|
+
const anchor = key === undefined ? { kind, subject } : { kind, subject, key };
|
|
280
|
+
return claimsFrom(anchor, paras, source);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Landing ─────────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
/** The live row this claim owns, if it has landed before. */
|
|
286
|
+
function liveImportRow(claim: Claim): MemoryRow | undefined {
|
|
287
|
+
return listMemories({
|
|
288
|
+
kind: claim.kind,
|
|
289
|
+
subject: claim.subject,
|
|
290
|
+
limit: LOOKUP_LIMIT,
|
|
291
|
+
}).find((row) => row.source.actor === IMPORT_ACTOR && row.key === claim.key);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Land one claim: skip an unchanged row, supersede a changed one, insert
|
|
296
|
+
* a new one. Keyed state goes in through `replaceStateKey`, so the newest
|
|
297
|
+
* snapshot of a family is the single live row for its key.
|
|
298
|
+
*/
|
|
299
|
+
function landClaim(claim: Claim): keyof ImportCounts {
|
|
300
|
+
const hash = memoryContentHash(
|
|
301
|
+
claim.kind,
|
|
302
|
+
claim.subject,
|
|
303
|
+
claim.key,
|
|
304
|
+
claim.text,
|
|
305
|
+
);
|
|
306
|
+
const existing = liveImportRow(claim);
|
|
307
|
+
if (existing?.contentHash === hash) return "skipped";
|
|
308
|
+
if (existing) {
|
|
309
|
+
supersedeMemory(existing.id, claim.text, RE_IMPORT_REASON);
|
|
310
|
+
return "superseded";
|
|
311
|
+
}
|
|
312
|
+
if (claim.kind === "state" && claim.key !== undefined) {
|
|
313
|
+
replaceStateKey(claim.key, claim.text, claim.source, {
|
|
314
|
+
subject: claim.subject,
|
|
315
|
+
trust: "operator",
|
|
316
|
+
});
|
|
317
|
+
} else {
|
|
318
|
+
assertMemory({
|
|
319
|
+
kind: claim.kind,
|
|
320
|
+
subject: claim.subject,
|
|
321
|
+
text: claim.text,
|
|
322
|
+
source: claim.source,
|
|
323
|
+
trust: "operator",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return "inserted";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function landAll(claims: Iterable<Claim>): ImportCounts {
|
|
330
|
+
const counts = emptyCounts();
|
|
331
|
+
for (const claim of claims) counts[landClaim(claim)] += 1;
|
|
332
|
+
return counts;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Import `memory.md`: one row per `## ` section, families collapsed
|
|
339
|
+
* first so only a status family's newest snapshot becomes its live state
|
|
340
|
+
* row. A missing file is not an error — a fresh install has none.
|
|
341
|
+
*/
|
|
342
|
+
export function importMemoryFile(path: string = files.memory): ImportCounts {
|
|
343
|
+
if (!existsSync(path)) return emptyCounts();
|
|
344
|
+
const content = readFileSync(path, "utf8");
|
|
345
|
+
const source: MemorySource = { actor: IMPORT_ACTOR, chat: basename(path) };
|
|
346
|
+
const { kept } = collapseFamilies(parseSections(content).sections);
|
|
347
|
+
const seen = new Map<string, number>();
|
|
348
|
+
return landAll(
|
|
349
|
+
kept.flatMap((section) => sectionClaims(section, source, seen)),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Import the daily notes: one `episode` per `YYYY-MM-DD.md`, subject the
|
|
355
|
+
* date. `diary-*.md` is the soul's first-person writing — a reflection,
|
|
356
|
+
* never a fact source (plan §3.1) — so the filename pattern excludes it.
|
|
357
|
+
*/
|
|
358
|
+
export function importDailyNotes(dir: string = dirs.dailyMemory): ImportCounts {
|
|
359
|
+
if (!existsSync(dir)) return emptyCounts();
|
|
360
|
+
let counts = emptyCounts();
|
|
361
|
+
for (const name of readdirSync(dir).sort()) {
|
|
362
|
+
const date = DAILY_NOTE.exec(name)?.[1];
|
|
363
|
+
if (!date) continue;
|
|
364
|
+
const paras = paragraphsOf(readFileSync(join(dir, name), "utf8"));
|
|
365
|
+
if (paras.length === 0) continue;
|
|
366
|
+
const source: MemorySource = { actor: IMPORT_ACTOR, chat: name };
|
|
367
|
+
const claims = claimsFrom(
|
|
368
|
+
{ kind: "episode", subject: date },
|
|
369
|
+
paras,
|
|
370
|
+
source,
|
|
371
|
+
);
|
|
372
|
+
counts = addCounts(counts, landAll(claims));
|
|
373
|
+
}
|
|
374
|
+
return counts;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Both sources, one set of counts — what `talon memory import` runs. */
|
|
378
|
+
export function importAll(): ImportCounts {
|
|
379
|
+
return addCounts(importMemoryFile(), importDailyNotes());
|
|
380
|
+
}
|