@team-harness/memory-algorithms 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +203 -0
  3. package/dist/contracts.d.ts +178 -0
  4. package/dist/contracts.js +1 -0
  5. package/dist/index.d.ts +14 -0
  6. package/dist/index.js +6 -0
  7. package/dist/runtime/documents.d.ts +15 -0
  8. package/dist/runtime/documents.js +209 -0
  9. package/dist/runtime/l1.d.ts +10 -0
  10. package/dist/runtime/l1.js +172 -0
  11. package/dist/runtime/run.d.ts +44 -0
  12. package/dist/runtime/run.js +191 -0
  13. package/dist/runtime/skill-workspace.d.ts +70 -0
  14. package/dist/runtime/skill-workspace.js +156 -0
  15. package/dist/runtime/skills.d.ts +9 -0
  16. package/dist/runtime/skills.js +48 -0
  17. package/dist/runtime/telemetry.d.ts +10 -0
  18. package/dist/runtime/telemetry.js +5 -0
  19. package/dist/runtime/tools.d.ts +15 -0
  20. package/dist/runtime/tools.js +5 -0
  21. package/dist/upstream/config.d.ts +1 -0
  22. package/dist/upstream/config.js +1 -0
  23. package/dist/upstream/core/conversation/l0-recorder.d.ts +6 -0
  24. package/dist/upstream/core/conversation/l0-recorder.js +1 -0
  25. package/dist/upstream/core/memory-prompt/composer.d.ts +6 -0
  26. package/dist/upstream/core/memory-prompt/composer.js +33 -0
  27. package/dist/upstream/core/memory-prompt/types.d.ts +103 -0
  28. package/dist/upstream/core/memory-prompt/types.js +21 -0
  29. package/dist/upstream/core/prompts/l1-dedup.d.ts +33 -0
  30. package/dist/upstream/core/prompts/l1-dedup.js +202 -0
  31. package/dist/upstream/core/prompts/l1-extraction.d.ts +24 -0
  32. package/dist/upstream/core/prompts/l1-extraction.js +400 -0
  33. package/dist/upstream/core/prompts/persona-generation.d.ts +29 -0
  34. package/dist/upstream/core/prompts/persona-generation.js +284 -0
  35. package/dist/upstream/core/prompts/scene-extraction.d.ts +40 -0
  36. package/dist/upstream/core/prompts/scene-extraction.js +534 -0
  37. package/dist/upstream/core/record/l1-dedup.d.ts +10 -0
  38. package/dist/upstream/core/record/l1-dedup.js +108 -0
  39. package/dist/upstream/core/record/l1-extractor.d.ts +33 -0
  40. package/dist/upstream/core/record/l1-extractor.js +128 -0
  41. package/dist/upstream/core/record/l1-writer.d.ts +95 -0
  42. package/dist/upstream/core/record/l1-writer.js +1 -0
  43. package/dist/upstream/core/scene/filename-normalizer.d.ts +6 -0
  44. package/dist/upstream/core/scene/filename-normalizer.js +30 -0
  45. package/dist/upstream/core/scene/scene-format.d.ts +26 -0
  46. package/dist/upstream/core/scene/scene-format.js +53 -0
  47. package/dist/upstream/core/scene/scene-index.d.ts +7 -0
  48. package/dist/upstream/core/scene/scene-index.js +1 -0
  49. package/dist/upstream/core/scene/scene-navigation.d.ts +66 -0
  50. package/dist/upstream/core/scene/scene-navigation.js +107 -0
  51. package/dist/upstream/core/skill/conversation-add/message-compressor.d.ts +47 -0
  52. package/dist/upstream/core/skill/conversation-add/message-compressor.js +58 -0
  53. package/dist/upstream/core/skill/conversation-add/oversize-strategy.d.ts +41 -0
  54. package/dist/upstream/core/skill/conversation-add/oversize-strategy.js +100 -0
  55. package/dist/upstream/core/skill/prompts/skill-review-prompt.d.ts +39 -0
  56. package/dist/upstream/core/skill/prompts/skill-review-prompt.js +197 -0
  57. package/dist/upstream/core/skill/skill-extractor.d.ts +146 -0
  58. package/dist/upstream/core/skill/skill-extractor.js +432 -0
  59. package/dist/upstream/core/skill/skill-format.d.ts +46 -0
  60. package/dist/upstream/core/skill/skill-format.js +191 -0
  61. package/dist/upstream/core/skill/skill-tools.d.ts +75 -0
  62. package/dist/upstream/core/skill/skill-tools.js +193 -0
  63. package/dist/upstream/core/skill/types.d.ts +324 -0
  64. package/dist/upstream/core/skill/types.js +7 -0
  65. package/dist/upstream/utils/sanitize.d.ts +96 -0
  66. package/dist/upstream/utils/sanitize.js +359 -0
  67. package/package.json +28 -0
  68. package/upstream/baseline.json +426 -0
  69. package/upstream/changes.md +81 -0
@@ -0,0 +1,33 @@
1
+ import type { Logger } from "../../../contracts.js";
2
+ import type { MemoryType } from "./l1-writer.js";
3
+ interface SceneSegment {
4
+ scene_name: string;
5
+ message_ids: string[];
6
+ memories: Array<{
7
+ content: string;
8
+ type: string;
9
+ priority: number;
10
+ source_message_ids: string[];
11
+ metadata: Record<string, unknown>;
12
+ }>;
13
+ }
14
+ export type L1EmptyReason = "llm_error" | "no_json" | "parse_fail" | "not_array" | "empty_scenes";
15
+ interface ParseExtractionOutcome {
16
+ scenes: SceneSegment[];
17
+ /** Populated iff we ended with 0 memories across all scenes. */
18
+ emptyReason?: L1EmptyReason;
19
+ }
20
+ /**
21
+ * Parse the LLM's JSON response into SceneSegment array.
22
+ * Expected format: [{scene_name, message_ids, memories: [...]}]
23
+ *
24
+ * Diagnostics contract:
25
+ * - Debug-level [l1-debug] lines dump raw content on NO_JSON / PARSE_FAIL
26
+ * (unchanged from prior behavior).
27
+ * - The returned `emptyReason` is the CANONICAL machine-readable label —
28
+ * `extractL1Memories` uses it to emit a single-line `l1-empty` warn when
29
+ * the final count is 0. See L1EmptyReason for the closed set.
30
+ */
31
+ export declare function parseExtractionResult(raw: string, logger?: Logger): ParseExtractionOutcome;
32
+ export declare function normalizeType(raw: string): MemoryType | null;
33
+ export {};
@@ -0,0 +1,128 @@
1
+ import { sanitizeJsonForParse } from "../../utils/sanitize.js";
2
+ const TAG = "[l1-parser]";
3
+ const VALID_TYPES = ["persona", "episodic", "instruction", "work_fact", "work_task", "work_method", "work_artifact"];
4
+ /**
5
+ * Parse the LLM's JSON response into SceneSegment array.
6
+ * Expected format: [{scene_name, message_ids, memories: [...]}]
7
+ *
8
+ * Diagnostics contract:
9
+ * - Debug-level [l1-debug] lines dump raw content on NO_JSON / PARSE_FAIL
10
+ * (unchanged from prior behavior).
11
+ * - The returned `emptyReason` is the CANONICAL machine-readable label —
12
+ * `extractL1Memories` uses it to emit a single-line `l1-empty` warn when
13
+ * the final count is 0. See L1EmptyReason for the closed set.
14
+ */
15
+ export function parseExtractionResult(raw, logger) {
16
+ try {
17
+ // ── Strip inline reasoning wrappers (A₂-style thinking models) ───────
18
+ // A₁ models (minimax-m2.7 / deepseek-v4-pro / o1 / o3 / Claude thinking)
19
+ // put reasoning in a separate `reasoning_content` field, so `content`
20
+ // itself is nothing but pure JSON — the strip below is a no-op.
21
+ //
22
+ // A₂ models (minimax-m3 / ep-2cocgw76 / GLM-4-thinking / qwq-32b / some
23
+ // vLLM-hosted DeepSeek-R1) inline reasoning as `<think>…</think>` inside
24
+ // the main `content`. Their think prose frequently contains stray `[`
25
+ // (message-id dumps, priority tags, and sometimes a full JSON template
26
+ // pre-written for "structure preview"), which fatally derails the greedy
27
+ // `/\[[\s\S]*\]/` array-match below by anchoring it inside the think
28
+ // section. So we peel `<think>` blocks BEFORE the array-match fires.
29
+ //
30
+ // Safeguards:
31
+ // • non-greedy `*?` + explicit `</think>` requirement — a truncated
32
+ // (max_tokens cap) think tag falls through as-is instead of eating
33
+ // the rest of the response.
34
+ // • `g` flag — rare multi-segment thinking is fully stripped.
35
+ // See docs/design/2026-09-03-l1-thinking-models-compatibility.md.
36
+ //
37
+ // NOTE: strip result goes to a new local (`stripped`) — we preserve the
38
+ // original `raw` so the NO_JSON / PARSE_FAIL dumps below still show
39
+ // exactly what came off the wire. If a future A₂ variant emits a shape
40
+ // this regex misses, ops need the pristine raw to diagnose it.
41
+ const stripped = raw.replace(/<think>[\s\S]*?<\/think>\s*/g, "");
42
+ // Strip markdown code block wrappers if present
43
+ let cleaned = stripped.trim();
44
+ if (cleaned.startsWith("```")) {
45
+ cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
46
+ }
47
+ // Try to extract JSON array
48
+ const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
49
+ if (!arrayMatch) {
50
+ logger?.warn?.(`${TAG} No JSON array found in extraction response`);
51
+ // [l1-debug] NO_JSON — dump the full ORIGINAL raw (not the stripped
52
+ // version), otherwise a broken <think> variant would leave no trace.
53
+ const rawPreview = raw.slice(0, 2048);
54
+ logger?.warn?.(`${TAG} [l1-debug] NO_JSON taskId=l1-extraction, rawLen=${raw.length}, strippedLen=${stripped.length}, cleanedLen=${cleaned.length}, rawFull=${JSON.stringify(rawPreview)}${raw.length > 2048 ? `…(+${raw.length - 2048})` : ""}`);
55
+ return { scenes: [], emptyReason: "no_json" };
56
+ }
57
+ // Sanitize control characters inside JSON string literals that LLM may produce.
58
+ // Some weaker OpenAI-compatible models occasionally emit bare identifiers for
59
+ // numeric fields (e.g. `"priority": sheet`). Repair only known safe fields and
60
+ // retry once so one bad scalar does not drop the whole extraction result.
61
+ const sanitized = sanitizeJsonForParse(arrayMatch[0]);
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(sanitized);
65
+ }
66
+ catch (err) {
67
+ const repaired = repairExtractionJson(sanitized);
68
+ if (repaired === sanitized)
69
+ throw err;
70
+ parsed = JSON.parse(repaired);
71
+ logger?.warn?.(`${TAG} Repaired non-strict extraction JSON: ${err instanceof Error ? err.message : String(err)}`);
72
+ }
73
+ if (!Array.isArray(parsed)) {
74
+ logger?.warn?.(`${TAG} Extraction response is not an array`);
75
+ return { scenes: [], emptyReason: "not_array" };
76
+ }
77
+ const scenes = [];
78
+ for (const item of parsed) {
79
+ if (!item || typeof item !== "object")
80
+ continue;
81
+ const s = item;
82
+ scenes.push({
83
+ scene_name: typeof s.scene_name === "string" ? s.scene_name : "未知情境",
84
+ message_ids: Array.isArray(s.message_ids) ? s.message_ids.map(String) : [],
85
+ memories: Array.isArray(s.memories)
86
+ ? s.memories
87
+ .filter((m) => m && typeof m === "object" && typeof m.content === "string" && m.content.length > 0)
88
+ .map((m) => ({
89
+ content: String(m.content),
90
+ type: String(m.type ?? "episodic"),
91
+ priority: typeof m.priority === "number" ? m.priority : 50,
92
+ source_message_ids: Array.isArray(m.source_message_ids) ? m.source_message_ids.map(String) : [],
93
+ metadata: (m.metadata && typeof m.metadata === "object" ? m.metadata : {}),
94
+ }))
95
+ : [],
96
+ });
97
+ }
98
+ const totalMemories = scenes.reduce((acc, sc) => acc + sc.memories.length, 0);
99
+ return {
100
+ scenes,
101
+ emptyReason: totalMemories === 0 ? "empty_scenes" : undefined,
102
+ };
103
+ }
104
+ catch (err) {
105
+ logger?.warn?.(`${TAG} Failed to parse extraction result: ${err instanceof Error ? err.message : String(err)}`);
106
+ logger?.warn?.(`${TAG} [l1-debug] PARSE_FAIL rawLen=${raw.length}, rawFull=${JSON.stringify(raw)}`);
107
+ return { scenes: [], emptyReason: "parse_fail" };
108
+ }
109
+ }
110
+ function repairExtractionJson(json) {
111
+ return json
112
+ .replace(/("priority"\s*:\s*)(?!-?\d+(?:\.\d+)?\s*[,}]|"[^"\\]*(?:\\.[^"\\]*)*"\s*[,}])([\s\S]*?)(?=,\s*"(?:content|type|priority|source_message_ids|metadata)"\s*:|[}\]])/g, (_m, prefix) => `${prefix}50`)
113
+ .replace(/,\s*([}\]])/g, "$1");
114
+ }
115
+ export function normalizeType(raw) {
116
+ const lower = raw.toLowerCase().trim();
117
+ if (VALID_TYPES.includes(lower)) {
118
+ return lower;
119
+ }
120
+ // Handle legacy type names
121
+ if (lower === "episode")
122
+ return "episodic";
123
+ if (lower === "instruct")
124
+ return "instruction";
125
+ if (lower === "preference")
126
+ return "persona"; // fold preference into persona
127
+ return null;
128
+ }
@@ -0,0 +1,95 @@
1
+ export type MemoryType = "persona" | "episodic" | "instruction" | "work_fact" | "work_task" | "work_method" | "work_artifact";
2
+ /** Metadata for episodic memories (activity time range) */
3
+ export interface EpisodicMetadata {
4
+ activity_start_time?: string;
5
+ activity_end_time?: string;
6
+ }
7
+ /**
8
+ * A persisted memory record in L1 JSONL files.
9
+ *
10
+ * v3 changes from v2:
11
+ * - `importance: "high"|"medium"|"low"` → `priority: number` (0-100, -1 for strict global instructions)
12
+ * - Added `scene_name`, `source_message_ids`, `metadata`, `timestamps`
13
+ * - Removed `keywords` (will be rebuilt from content for search)
14
+ * - MemoryType reduced from 4 to 3 (removed "preference", folded into "persona")
15
+ */
16
+ export interface MemoryRecord {
17
+ /** Unique ID for dedup updates */
18
+ id: string;
19
+ /** Memory content */
20
+ content: string;
21
+ /** Memory type: persona / episodic / instruction */
22
+ type: MemoryType;
23
+ /** Priority score: 0-100 (higher = more important), -1 = strict global instruction */
24
+ priority: number;
25
+ /** Scene name this memory belongs to */
26
+ scene_name: string;
27
+ /** Source message IDs that contributed to this memory */
28
+ source_message_ids: string[];
29
+ /** Type-specific metadata (e.g., activity_start_time for episodic) */
30
+ metadata: EpisodicMetadata | Record<string, never>;
31
+ /** Timestamp trail: all timestamps related to this memory (for merge history tracking) */
32
+ timestamps: string[];
33
+ /** Creation timestamp (ISO) */
34
+ createdAt: string;
35
+ /** Last update timestamp (ISO) */
36
+ updatedAt: string;
37
+ /** Monotonic version. New memories start at 1; update/merge increments by 1. */
38
+ version?: number;
39
+ /** Source session key (conversation channel identifier) */
40
+ sessionKey: string;
41
+ /** Source session ID (single conversation instance identifier) */
42
+ sessionId: string;
43
+ /** Optional task dimension for L0/L1 filtering. */
44
+ taskId?: string;
45
+ /**
46
+ * Three-dim tenancy isolation (new in this branch).
47
+ *
48
+ * `userId` / `agentId` are mandatory for new writes once gateway-level
49
+ * isolation enforcement is on, but kept optional on the type to avoid
50
+ * breaking pre-isolation call sites and tests during rollout. The SQLite
51
+ * upsert defaults them to '' if missing; the migration script backfills
52
+ * existing rows with `__legacy__`.
53
+ *
54
+ * See `docs/l0l3-tenant-isolation-design.md`.
55
+ */
56
+ teamId?: string;
57
+ userId?: string;
58
+ agentId?: string;
59
+ }
60
+ /**
61
+ * A memory as extracted by LLM (before dedup / persistence).
62
+ * Matches the output format of Kenty's extraction prompt.
63
+ */
64
+ export interface ExtractedMemory {
65
+ content: string;
66
+ type: MemoryType;
67
+ priority: number;
68
+ source_message_ids: string[];
69
+ metadata: EpisodicMetadata | Record<string, never>;
70
+ /** Scene name this memory was extracted in */
71
+ scene_name: string;
72
+ }
73
+ export type DedupAction = "store" | "update" | "merge" | "skip";
74
+ /**
75
+ * v3 batch dedup decision — one per new memory, aligned with Kenty's conflict detection prompt.
76
+ *
77
+ * Key changes:
78
+ * - `targetId` → `target_ids` (array, supports multi-target merge/update)
79
+ * - Added `merged_type`, `merged_priority`, `merged_timestamps` for cross-type merge
80
+ */
81
+ export interface DedupDecision {
82
+ /** Which new memory this decision is about */
83
+ record_id: string;
84
+ action: DedupAction;
85
+ /** IDs of existing records to replace/remove (for update/merge) */
86
+ target_ids: string[];
87
+ /** Merged/updated content text (for update/merge) */
88
+ merged_content?: string;
89
+ /** Best type after merge (for update/merge, may differ from original) */
90
+ merged_type?: MemoryType;
91
+ /** Priority after merge (for update/merge) */
92
+ merged_priority?: number;
93
+ /** Union of all related timestamps (for update/merge) */
94
+ merged_timestamps?: string[];
95
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ export declare function normalizeSceneFilename(name: string): string;
2
+ /**
3
+ * Return whether a filename already matches its normalized form.
4
+ * Faster than computing the normalized form when callers only need a yes/no.
5
+ */
6
+ export declare function isNormalizedSceneFilename(name: string): boolean;
@@ -0,0 +1,30 @@
1
+ export function normalizeSceneFilename(name) {
2
+ if (!name)
3
+ return "scene.md";
4
+ // Strip directory components defensively — we only normalize the basename.
5
+ const base = name.replace(/^.*[\\/]/, "");
6
+ // Detect & strip `.md` (case-insensitive). Always re-emit lowercase `.md`.
7
+ const lower = base.toLowerCase();
8
+ const hasMd = lower.endsWith(".md");
9
+ const stem = hasMd ? base.slice(0, -3) : base;
10
+ const safe = stem
11
+ // Replace whitespace runs (incl. NBSP, full-width space) with `-`
12
+ .replace(/[\s\u00A0\u3000]+/g, "-")
13
+ // Drop quotes, brackets, and punctuation known to break shells/markdown.
14
+ // Keep alphanumerics, CJK ideographs, `-`, `_`, `.`.
15
+ .replace(/[()[\]{}<>'"`,;:!?*|/\\=&%$#@^~+]/g, "")
16
+ // Collapse consecutive separators.
17
+ .replace(/-{2,}/g, "-")
18
+ .replace(/_{2,}/g, "_")
19
+ .replace(/\.{2,}/g, ".")
20
+ // Trim leading / trailing separators.
21
+ .replace(/^[-_.]+|[-_.]+$/g, "");
22
+ return (safe || "scene") + ".md";
23
+ }
24
+ /**
25
+ * Return whether a filename already matches its normalized form.
26
+ * Faster than computing the normalized form when callers only need a yes/no.
27
+ */
28
+ export function isNormalizedSceneFilename(name) {
29
+ return normalizeSceneFilename(name) === name;
30
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Scene Block file format: parse and format the META-delimited Markdown files.
3
+ */
4
+ export interface SceneBlockMeta {
5
+ created: string;
6
+ updated: string;
7
+ summary: string;
8
+ heat: number;
9
+ }
10
+ export interface SceneBlock {
11
+ filename: string;
12
+ meta: SceneBlockMeta;
13
+ content: string;
14
+ }
15
+ /**
16
+ * Parse a Scene Block file into structured data.
17
+ */
18
+ export declare function parseSceneBlock(raw: string, filename: string): SceneBlock;
19
+ /**
20
+ * Format a Scene Block back into file content.
21
+ */
22
+ export declare function formatSceneBlock(meta: SceneBlockMeta, content: string): string;
23
+ /**
24
+ * Format the META section.
25
+ */
26
+ export declare function formatMeta(meta: SceneBlockMeta): string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Scene Block file format: parse and format the META-delimited Markdown files.
3
+ */
4
+ const META_START = "-----META-START-----";
5
+ const META_END = "-----META-END-----";
6
+ /**
7
+ * Parse a Scene Block file into structured data.
8
+ */
9
+ export function parseSceneBlock(raw, filename) {
10
+ const startIdx = raw.indexOf(META_START);
11
+ const endIdx = raw.indexOf(META_END);
12
+ if (startIdx === -1 || endIdx === -1) {
13
+ // No META section — treat entire file as content
14
+ return {
15
+ filename,
16
+ meta: { created: "", updated: "", summary: "", heat: 0 },
17
+ content: raw.trim(),
18
+ };
19
+ }
20
+ const metaBlock = raw.slice(startIdx + META_START.length, endIdx).trim();
21
+ const content = raw.slice(endIdx + META_END.length).trim();
22
+ const meta = {
23
+ created: extractMetaField(metaBlock, "created"),
24
+ updated: extractMetaField(metaBlock, "updated"),
25
+ summary: extractMetaField(metaBlock, "summary"),
26
+ heat: parseInt(extractMetaField(metaBlock, "heat"), 10) || 0,
27
+ };
28
+ return { filename, meta, content };
29
+ }
30
+ /**
31
+ * Format a Scene Block back into file content.
32
+ */
33
+ export function formatSceneBlock(meta, content) {
34
+ return `${formatMeta(meta)}\n\n${content}`;
35
+ }
36
+ /**
37
+ * Format the META section.
38
+ */
39
+ export function formatMeta(meta) {
40
+ return [
41
+ META_START,
42
+ `created: ${meta.created}`,
43
+ `updated: ${meta.updated}`,
44
+ `summary: ${meta.summary}`,
45
+ `heat: ${meta.heat}`,
46
+ META_END,
47
+ ].join("\n");
48
+ }
49
+ function extractMetaField(metaBlock, field) {
50
+ const re = new RegExp(`^${field}:\\s*(.*)$`, "m");
51
+ const m = metaBlock.match(re);
52
+ return m ? m[1].trim() : "";
53
+ }
@@ -0,0 +1,7 @@
1
+ export interface SceneIndexEntry {
2
+ filename: string;
3
+ summary: string;
4
+ heat: number;
5
+ created: string;
6
+ updated: string;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Scene navigation: generates a summary navigation section appended to persona.md.
3
+ *
4
+ * The navigation includes **absolute** file paths so the agent can directly
5
+ * use read_file for on-demand scene loading (progressive disclosure).
6
+ */
7
+ import type { SceneIndexEntry } from "./scene-index.js";
8
+ /**
9
+ * Footer for backends addressed by storage key rather than filesystem path.
10
+ * The reading tool is a property of the mounted surface, not of the backend,
11
+ * so it is supplied by whoever assembles the two.
12
+ */
13
+ export declare function storageNavFooter(readTool: string): string;
14
+ export interface SceneNavigationRenderOptions {
15
+ /**
16
+ * Resolves an entry to the path the reader must pass back. Callers hand in
17
+ * the same resolver they serve reads from, so a rendered path is by
18
+ * construction a path that can be read (design doc D12).
19
+ */
20
+ pathFor: (entry: SceneIndexEntry) => string;
21
+ /** Tool the model should call, named in the intro line. */
22
+ readTool: string;
23
+ footer: string;
24
+ }
25
+ /**
26
+ * Generate the scene navigation Markdown section.
27
+ *
28
+ * @param entries - Scene index entries
29
+ * @param dataDir - Absolute path to the plugin data directory; when provided
30
+ * and useCos=false, paths are absolute for read_file.
31
+ * @param useCos - When true, paths use scenes/ prefix and footer says tdai_read_file.
32
+ *
33
+ * ⚠️ KNOWN BROKEN — the `useCos=true` branch emits unreadable paths. Do not
34
+ * build on it, and do not "fix" it by renaming scenes/ -> scene_blocks/ alone.
35
+ *
36
+ * It is wrong on two levels: the directory name is a leftover from the deleted
37
+ * CosPathResolver design (real layout is scene_blocks/), and the emitted key
38
+ * also omits the profiles/{scope}/ segment, because navigation is generated
39
+ * against a scoped storage view while the read tool holds an unscoped root.
40
+ *
41
+ * Left unfixed deliberately: no live read path reaches it. The Proxy reads L2
42
+ * through /v3/scenario/ls + /v3/scenario/read, and the OpenClaw plugin builds
43
+ * TdaiCore without a storage adapter, so useCos is always false there. The one
44
+ * caller that can set useCos=true is the legacy POST /recall endpoint, which
45
+ * currently has no consumer.
46
+ *
47
+ * Reviving POST /recall on the main path REQUIRES fixing this first. The real
48
+ * fix is to make navigation and the read tool share one scoped resolver, which
49
+ * is tracked as P10.
50
+ *
51
+ * See docs/design/mongodb/design/2026-08-27-core-storage-abstraction-design.md
52
+ * (D13, §10.C3.1, known issue L6).
53
+ */
54
+ export declare function generateSceneNavigation(entries: SceneIndexEntry[], dataDir?: string, useCos?: boolean): string;
55
+ /**
56
+ * Render the navigation section from entries and a caller-supplied resolver.
57
+ *
58
+ * Taking `pathFor` instead of a mode flag is the point: a backend renders the
59
+ * paths it actually serves, so navigation and reads cannot drift apart the way
60
+ * the `useCos` branch above did.
61
+ */
62
+ export declare function renderSceneNavigation(entries: SceneIndexEntry[], opts: SceneNavigationRenderOptions): string;
63
+ /**
64
+ * Strip the scene navigation section from persona content.
65
+ */
66
+ export declare function stripSceneNavigation(personaContent: string): string;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Scene navigation: generates a summary navigation section appended to persona.md.
3
+ *
4
+ * The navigation includes **absolute** file paths so the agent can directly
5
+ * use read_file for on-demand scene loading (progressive disclosure).
6
+ */
7
+ import path from "node:path";
8
+ const NAV_HEADER = "---\n## 🗺️ Scene Navigation (Scene Index)";
9
+ const NAV_FOOTER_LOCAL = `📌 使用说明:
10
+ - Path 是 scene block 的绝对路径,可直接使用 **read** 工具读取完整内容(参数: filePath)
11
+ - 热度:该场景被记忆命中的累计次数,越高越重要
12
+ - Summary:场景的核心要点摘要`;
13
+ /**
14
+ * Footer for backends addressed by storage key rather than filesystem path.
15
+ * The reading tool is a property of the mounted surface, not of the backend,
16
+ * so it is supplied by whoever assembles the two.
17
+ */
18
+ export function storageNavFooter(readTool) {
19
+ return `📌 使用说明:
20
+ - Path 是 scene block 的存储路径,请使用 **${readTool}** 工具读取完整内容(参数: path)
21
+ - 热度:该场景被记忆命中的累计次数,越高越重要
22
+ - Summary:场景的核心要点摘要`;
23
+ }
24
+ /**
25
+ * Build a fire-emoji string based on heat value (visual priority cue for the agent).
26
+ */
27
+ function heatEmoji(heat) {
28
+ if (heat >= 1000)
29
+ return " 🔥🔥🔥🔥🔥";
30
+ if (heat >= 500)
31
+ return " 🔥🔥🔥🔥";
32
+ if (heat >= 200)
33
+ return " 🔥🔥🔥";
34
+ if (heat >= 100)
35
+ return " 🔥🔥";
36
+ if (heat >= 50)
37
+ return " 🔥";
38
+ return "";
39
+ }
40
+ /**
41
+ * Generate the scene navigation Markdown section.
42
+ *
43
+ * @param entries - Scene index entries
44
+ * @param dataDir - Absolute path to the plugin data directory; when provided
45
+ * and useCos=false, paths are absolute for read_file.
46
+ * @param useCos - When true, paths use scenes/ prefix and footer says tdai_read_file.
47
+ *
48
+ * ⚠️ KNOWN BROKEN — the `useCos=true` branch emits unreadable paths. Do not
49
+ * build on it, and do not "fix" it by renaming scenes/ -> scene_blocks/ alone.
50
+ *
51
+ * It is wrong on two levels: the directory name is a leftover from the deleted
52
+ * CosPathResolver design (real layout is scene_blocks/), and the emitted key
53
+ * also omits the profiles/{scope}/ segment, because navigation is generated
54
+ * against a scoped storage view while the read tool holds an unscoped root.
55
+ *
56
+ * Left unfixed deliberately: no live read path reaches it. The Proxy reads L2
57
+ * through /v3/scenario/ls + /v3/scenario/read, and the OpenClaw plugin builds
58
+ * TdaiCore without a storage adapter, so useCos is always false there. The one
59
+ * caller that can set useCos=true is the legacy POST /recall endpoint, which
60
+ * currently has no consumer.
61
+ *
62
+ * Reviving POST /recall on the main path REQUIRES fixing this first. The real
63
+ * fix is to make navigation and the read tool share one scoped resolver, which
64
+ * is tracked as P10.
65
+ *
66
+ * See docs/design/mongodb/design/2026-08-27-core-storage-abstraction-design.md
67
+ * (D13, §10.C3.1, known issue L6).
68
+ */
69
+ export function generateSceneNavigation(entries, dataDir, useCos = false) {
70
+ return renderSceneNavigation(entries, {
71
+ pathFor: (e) => useCos
72
+ ? `scenes/${e.filename}`
73
+ : dataDir
74
+ ? path.join(dataDir, "scene_blocks", e.filename)
75
+ : `scene_blocks/${e.filename}`,
76
+ readTool: useCos ? "tdai_read_file" : "read",
77
+ footer: useCos ? storageNavFooter("tdai_read_file") : NAV_FOOTER_LOCAL,
78
+ });
79
+ }
80
+ /**
81
+ * Render the navigation section from entries and a caller-supplied resolver.
82
+ *
83
+ * Taking `pathFor` instead of a mode flag is the point: a backend renders the
84
+ * paths it actually serves, so navigation and reads cannot drift apart the way
85
+ * the `useCos` branch above did.
86
+ */
87
+ export function renderSceneNavigation(entries, opts) {
88
+ if (entries.length === 0)
89
+ return "";
90
+ const sorted = [...entries].sort((a, b) => b.heat - a.heat);
91
+ const blocks = sorted.map((e) => {
92
+ const pathLine = `### Path: ${opts.pathFor(e)}`;
93
+ const heatLine = `**热度**: ${e.heat}${heatEmoji(e.heat)}${e.updated ? ` | **更新**: ${e.updated}` : ""}`;
94
+ const summaryLine = `Summary: ${e.summary}`;
95
+ return `${pathLine}\n${heatLine}\n${summaryLine}`;
96
+ });
97
+ return `${NAV_HEADER}\n*以下是当前场景记忆的索引,可根据需要 ${opts.readTool} 读取详细内容。*\n\n${blocks.join("\n\n")}\n\n${opts.footer}`;
98
+ }
99
+ /**
100
+ * Strip the scene navigation section from persona content.
101
+ */
102
+ export function stripSceneNavigation(personaContent) {
103
+ const idx = personaContent.indexOf(NAV_HEADER);
104
+ if (idx === -1)
105
+ return personaContent;
106
+ return personaContent.slice(0, idx).trimEnd();
107
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * SkillMessageCompressor — compress oversized tool_call / tool_result payloads
3
+ * before they enter the skill buffer.
4
+ *
5
+ * 对应设计文档 `2026-07-15-skill-trigger-in-core-design.md` §6:
6
+ * - 只压 tool_call / tool_result;user / assistant / system 永不压缩
7
+ * - content 字节数 > 2KB 才压缩,头 1KB + 尾 1KB + 中间占位提示
8
+ * - metadata.truncated = true, metadata.original_bytes = <原始字节数>
9
+ *
10
+ * 字节切分实现说明:
11
+ * 直接对 Buffer 做 slice 可能截到 UTF-8 多字节字符的中间,转回 string 会
12
+ * 出现 U+FFFD 替换字符。这里我们对 Buffer 做切片、再 toString('utf8'),
13
+ * Node 侧会把结尾/开头不完整的多字节序列替换成 U+FFFD——但整体不影响
14
+ * 下游 LLM review 的语义(提示语里说明了截断)。对于 tool payload 这种
15
+ * 通常是 ASCII/JSON 的场景,边界字符损坏概率极低;测试对齐宽松断言。
16
+ */
17
+ export type CompressibleRole = "user" | "assistant" | "tool_call" | "tool_result" | "system";
18
+ export interface CompressibleMessage {
19
+ role: CompressibleRole;
20
+ content: string;
21
+ /** Optional tool identity for tool_call / tool_result. */
22
+ tool_name?: string;
23
+ tool_call_id?: string;
24
+ timestamp?: number;
25
+ metadata?: Record<string, unknown>;
26
+ }
27
+ export interface CompressOptions {
28
+ /** 字节阈值;tool 消息 content bytes > 阈值才压缩。默认 2048 (2KB)。 */
29
+ toolContentThresholdBytes: number;
30
+ /** 头部保留字节数。默认 1024 (1KB)。 */
31
+ headBytes: number;
32
+ /** 尾部保留字节数。默认 1024 (1KB)。 */
33
+ tailBytes: number;
34
+ /** 中间占位字符串。 */
35
+ placeholder: string;
36
+ }
37
+ export declare const DEFAULT_COMPRESS_OPTIONS: CompressOptions;
38
+ /**
39
+ * Compress a single message. Returns a new object if compressed, otherwise
40
+ * returns the original message reference unchanged.
41
+ */
42
+ export declare function compressMessage(msg: CompressibleMessage, optsOverride?: Partial<CompressOptions>): CompressibleMessage;
43
+ /**
44
+ * Compress an array of messages. Returns a new array; unchanged messages
45
+ * share the original reference (identity-preserving for downstream diffing).
46
+ */
47
+ export declare function compressMessages(messages: CompressibleMessage[], optsOverride?: Partial<CompressOptions>): CompressibleMessage[];