dsh-companion-memory-core 0.0.0-stage → 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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +101 -3
  3. package/THIRD_PARTY_NOTICES.md +17 -0
  4. package/cordis.patch.yml +18 -0
  5. package/icon.png +0 -0
  6. package/lib/README.md +3 -0
  7. package/lib/client/index.d.ts +8 -0
  8. package/lib/client/index.js +9 -0
  9. package/lib/client/panel.d.ts +21 -0
  10. package/lib/client/panel.js +36 -0
  11. package/lib/core/context.d.ts +6 -0
  12. package/lib/core/context.js +14 -0
  13. package/lib/core/disclosure.d.ts +20 -0
  14. package/lib/core/disclosure.js +38 -0
  15. package/lib/core/index.d.ts +11 -0
  16. package/lib/core/index.js +11 -0
  17. package/lib/core/indexer.d.ts +14 -0
  18. package/lib/core/indexer.js +61 -0
  19. package/lib/core/llm-adapter.d.ts +17 -0
  20. package/lib/core/llm-adapter.js +8 -0
  21. package/lib/core/llm-reviewer.d.ts +25 -0
  22. package/lib/core/llm-reviewer.js +160 -0
  23. package/lib/core/paths.d.ts +17 -0
  24. package/lib/core/paths.js +30 -0
  25. package/lib/core/recall.d.ts +37 -0
  26. package/lib/core/recall.js +38 -0
  27. package/lib/core/review.d.ts +39 -0
  28. package/lib/core/review.js +41 -0
  29. package/lib/core/schema.d.ts +57 -0
  30. package/lib/core/schema.js +96 -0
  31. package/lib/core/store.d.ts +28 -0
  32. package/lib/core/store.js +89 -0
  33. package/lib/core/writer.d.ts +11 -0
  34. package/lib/core/writer.js +47 -0
  35. package/lib/host/dsh/bindings.d.ts +15 -0
  36. package/lib/host/dsh/bindings.js +28 -0
  37. package/lib/host/dsh/contracts.d.ts +84 -0
  38. package/lib/host/dsh/contracts.js +1 -0
  39. package/lib/host/dsh/events.d.ts +13 -0
  40. package/lib/host/dsh/events.js +91 -0
  41. package/lib/host/dsh/llm.d.ts +19 -0
  42. package/lib/host/dsh/llm.js +52 -0
  43. package/lib/host/dsh/plugin.d.ts +34 -0
  44. package/lib/host/dsh/plugin.js +239 -0
  45. package/lib/host/dsh/prompt.d.ts +4 -0
  46. package/lib/host/dsh/prompt.js +10 -0
  47. package/lib/host/dsh/timing.d.ts +13 -0
  48. package/lib/host/dsh/timing.js +29 -0
  49. package/lib/host/dsh/tools.d.ts +10 -0
  50. package/lib/host/dsh/tools.js +51 -0
  51. package/lib/index.d.ts +3 -0
  52. package/lib/index.js +3 -0
  53. package/package.json +77 -6
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 1326007879-sys
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # dsh-companion-memory-core (v0)
2
+
3
+ Local, host-independent long-term memory core for DeepSeek Harness. Plain-text
4
+ notes, a bounded index, auditable evidence. No runtime dependencies, no external
5
+ services, no native modules.
6
+
7
+ Design notes live in the project plan and are not shipped in the npm package.
8
+
9
+ ## Current status
10
+
11
+ The v0 implementation covers the data contract, three-source store, bounded
12
+ index, keyword recall, layered disclosure, five tools, host `llm/stream`
13
+ review, and a read-only client entry. `lib/` is generated by `npm run build`
14
+ and shipped in the package.
15
+
16
+ Review borrows the host's `ctx.llm.stream()` through `LlmReviewer`; it stays
17
+ disabled by default via `maintenanceMode: off`. The idle-review chain (§7-3) is
18
+ covered end to end by a host-shaped stream test, so no model quota is spent in CI.
19
+ The client entry (`exports["./client"]`) registers a read-only sidebar action
20
+ through the host's slot service; note browsing stays on the tool surface. A
21
+ custom pane is deferred: host remote namespaces are Typert-generated, so a
22
+ third-party plugin cannot register a new RPC group at runtime.
23
+
24
+ The host exposes no `learning` / `contentMode` / `toolVerified` admission
25
+ metadata, so unverified `tool/result` output is deliberately never archived.
26
+ User and assistant messages are the only long-term sources in v0.
27
+
28
+ The npm package name is `dsh-companion-memory-core`, distinct from the GitHub
29
+ repository name.
30
+
31
+ ## Layout
32
+
33
+ ```
34
+ src/core/ host-independent: paths, schema, store, indexer, recall,
35
+ disclosure, context, review, writer, llm-adapter, llm-reviewer
36
+ src/host/dsh/ the only layer allowed to touch the harness
37
+ src/client/ read-only sidebar entry (exports["./client"])
38
+ lib/ build output, the only code shipped in the npm package
39
+ ```
40
+
41
+ ## Build and install
42
+
43
+ ```sh
44
+ npm install --no-package-lock --legacy-peer-deps
45
+ npm run build # emits lib/**/*.js + .d.ts
46
+ npm test
47
+ npm pack # sanity-check the tarball contents
48
+ dsh plugin --profile test add .
49
+ ```
50
+
51
+ Local verification should use a dedicated `test` profile, never the daily one.
52
+
53
+ ## Data location
54
+
55
+ Default `~/.companion/`, overridable with `config.memoryRoot` or the
56
+ `COMPANION_MEMORY_ROOT` environment variable. Everything is plain text and
57
+ stays on the machine:
58
+
59
+ ```
60
+ MEMORY.md bounded index (200 lines / 25 KB)
61
+ meta.json schema version, writer epoch, review watermark
62
+ notes/*.md one fact per file, with evidence and sourceRefs
63
+ cold/*.jsonl append-only raw conversation lines
64
+ journal/ reserved for v1
65
+ persona.md reserved for v1
66
+ profile.md reserved for v1
67
+ state.json reserved for v1
68
+ archive/ soft-archived notes
69
+ index/ rebuildable keyword index
70
+ ```
71
+
72
+ Uninstalling the plugin never deletes this directory. `memory_forget` archives a
73
+ note and removes its index entry; it does not touch the host's own chat history.
74
+
75
+ ## Privacy and capability boundaries
76
+
77
+ - Memory is **unencrypted plain text**; any local process can read it. Never
78
+ store credential values, only pointers to where they live.
79
+ - The core never reads credentials and never opens network connections.
80
+ - Review requests borrow the host's model through `LlmAdapter`; the package ships
81
+ no API key and no fallback provider.
82
+ - `maintenanceMode: off` must produce zero model calls.
83
+
84
+ ## Before publishing
85
+
86
+ 1. Confirm peer ranges against the actual dsh release in use (verified against
87
+ desktop `0.2.2-rc.1` / `cordis 4.1.0`).
88
+ 2. Run `npm run build`, verify `lib/` and `icon.png` are in the tarball, then
89
+ `npm publish`.
90
+ 3. Add the `dsh-plugin` topic after the repository is at least one day old, then
91
+ submit one catalog YAML entry
92
+ `data/plugins/1326007879-sys__companion-memory.yml`. Note the 24-hour npm
93
+ release-age window.
94
+ 4. Keep descriptions factual: stable memory, local notes, bounded index. Do not
95
+ claim consciousness, emotions, or model training.
96
+
97
+ ## Out of scope for v0
98
+
99
+ Persona/state injection, moods, proactive messages, journaling, vectors, graph
100
+ links, star map, cross-host adapters, encryption, hash-chain audit, standalone
101
+ service, model training.
@@ -0,0 +1,17 @@
1
+ # Third-party notices
2
+
3
+ This project does not vendor third-party source code. The following projects
4
+ were studied for their data formats, operating rules, and parameter defaults;
5
+ only concepts and field names are reused.
6
+
7
+ | Project | License | What is referenced |
8
+ |---|---|---|
9
+ | `tinqiao-oss/engramory` | MIT | One fact per Markdown file, bounded `MEMORY.md` index, write-before-limit discipline, single-writer/many-readers rule |
10
+ | `Soulmate793/dsh-memory` | MIT | Recall score weights, decay and consolidation parameter defaults |
11
+ | `123zxs345/dsh-plugin-companion-memory` | MIT | Session event filtering rules, stable source identity, layered disclosure shape |
12
+ | `painter-7/memory-starmap` | **no LICENSE** | Data contract shape only (`cold` / `dream` / `daybook`, `evidence`, `supersededBy`). No code copied. |
13
+
14
+ ## Publishing notes
15
+
16
+ This file is included in the published package via `package.json.files`. The
17
+ catalog icon is a bitmap (`icon.png`), not SVG.
@@ -0,0 +1,18 @@
1
+ # Each profile loads its own plugin instance; data lives outside the profile directory.
2
+ - insert:
3
+ - id: companion-memory
4
+ name: dsh-companion-memory-core
5
+ disabled: !!js "!ctx.get('profileContext')"
6
+ config:
7
+ memoryRoot: "" # empty = ~/.companion
8
+ companionId: default
9
+ idleReviewSeconds: 15
10
+ reviewMaxDelaySeconds: 60
11
+ candidateBudget: 24
12
+ contextTokens: 800
13
+ minimumScore: 0.05
14
+ maxLines: 200
15
+ maxBytes: 25600
16
+ reviewProvider: "" # empty = current session request-header route
17
+ reviewModel: ""
18
+ maintenanceMode: off
package/icon.png ADDED
Binary file
package/lib/README.md ADDED
@@ -0,0 +1,3 @@
1
+ Build output lives here; `npm run build` emits `lib/**/*.js` and `.d.ts`
2
+ from `src/`. Only `lib/` is shipped as executable code in the npm package.
3
+ These files are generated: change `src/`, never hand-edit `lib/`.
@@ -0,0 +1,8 @@
1
+ import { type ClientContext } from './panel.js';
2
+ /** Client-side Cordis plugin entry, discovered via exports["./client"]. */
3
+ declare const _default: {
4
+ name: string;
5
+ apply(ctx: ClientContext): void;
6
+ };
7
+ export default _default;
8
+ export { mountPanel, PANEL_ID, TOOL_NAMES, type ClientContext } from './panel.js';
@@ -0,0 +1,9 @@
1
+ import { mountPanel } from './panel.js';
2
+ /** Client-side Cordis plugin entry, discovered via exports["./client"]. */
3
+ export default {
4
+ name: 'dsh-companion-memory-client',
5
+ apply(ctx) {
6
+ mountPanel(ctx);
7
+ },
8
+ };
9
+ export { mountPanel, PANEL_ID, TOOL_NAMES } from './panel.js';
@@ -0,0 +1,21 @@
1
+ export declare const PANEL_ID = "companion-memory";
2
+ export declare const TOOL_NAMES: readonly ["memory_recall", "memory_read", "memory_save", "memory_forget", "memory_stats"];
3
+ interface ClientSlots {
4
+ inject(name: string, factory: () => unknown): unknown;
5
+ register(spec: Record<string, unknown>): unknown;
6
+ }
7
+ export interface ClientContext {
8
+ slots?: ClientSlots;
9
+ get?(name: string): unknown;
10
+ }
11
+ /**
12
+ * Registers a read-only sidebar entry through the host's slot service.
13
+ *
14
+ * The host renders `sidebar.footer.action` itself, so no React runtime or
15
+ * network client is added to this package. Full note browsing stays on the
16
+ * tool surface (`memory_stats` / `memory_recall` / `memory_read`); a custom
17
+ * pane with its own component would require a host-side remote RPC group.
18
+ * Registration is best-effort: a missing slot service must never break boot.
19
+ */
20
+ export declare function mountPanel(client: ClientContext): boolean;
21
+ export {};
@@ -0,0 +1,36 @@
1
+ export const PANEL_ID = 'companion-memory';
2
+ export const TOOL_NAMES = [
3
+ 'memory_recall',
4
+ 'memory_read',
5
+ 'memory_save',
6
+ 'memory_forget',
7
+ 'memory_stats',
8
+ ];
9
+ /**
10
+ * Registers a read-only sidebar entry through the host's slot service.
11
+ *
12
+ * The host renders `sidebar.footer.action` itself, so no React runtime or
13
+ * network client is added to this package. Full note browsing stays on the
14
+ * tool surface (`memory_stats` / `memory_recall` / `memory_read`); a custom
15
+ * pane with its own component would require a host-side remote RPC group.
16
+ * Registration is best-effort: a missing slot service must never break boot.
17
+ */
18
+ export function mountPanel(client) {
19
+ const slots = client.slots;
20
+ if (typeof slots?.inject !== 'function' || typeof slots.register !== 'function')
21
+ return false;
22
+ try {
23
+ slots.inject('sidebar.footer.action', () => slots.register({
24
+ name: 'sidebar.footer.action',
25
+ id: PANEL_ID,
26
+ inject: () => ({
27
+ readOnly: true,
28
+ toolNames: [...TOOL_NAMES],
29
+ }),
30
+ }));
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
@@ -0,0 +1,6 @@
1
+ import type { MemoryStore } from './store.js';
2
+ /**
3
+ * Renders the prompt snapshot from the bounded index only. `cold` lines never
4
+ * contribute, so the injected context stays constant as the log grows.
5
+ */
6
+ export declare function buildIndexSnapshot(store: MemoryStore, budget: number): Promise<string>;
@@ -0,0 +1,14 @@
1
+ import { estimateTokens } from './disclosure.js';
2
+ /**
3
+ * Renders the prompt snapshot from the bounded index only. `cold` lines never
4
+ * contribute, so the injected context stays constant as the log grows.
5
+ */
6
+ export async function buildIndexSnapshot(store, budget) {
7
+ const kept = [];
8
+ for (const line of await store.readIndex()) {
9
+ if (estimateTokens([...kept, line]) > budget)
10
+ break;
11
+ kept.push(line);
12
+ }
13
+ return kept.join('\n');
14
+ }
@@ -0,0 +1,20 @@
1
+ export interface SummaryItem {
2
+ memoryId: string;
3
+ revision: number;
4
+ summary: string;
5
+ availableLayers: ['refined', 'raw'];
6
+ }
7
+ export interface DisclosureCandidate {
8
+ memoryId: string;
9
+ revision: number;
10
+ summary: string;
11
+ detailsUnavailable?: boolean;
12
+ }
13
+ export declare function estimateTokens(value: unknown): number;
14
+ /** Summaries are the only layer that enters the prompt; details are fetched on demand. */
15
+ export declare function summaryItems(memories: readonly DisclosureCandidate[], tokenBudget: number, maxItems?: number): SummaryItem[];
16
+ /** Raw pages never truncate a source in the middle. */
17
+ export declare function rawPage<T>(sources: readonly T[], offset?: number, pageSize?: number): {
18
+ sources: T[];
19
+ nextOffset?: number;
20
+ };
@@ -0,0 +1,38 @@
1
+ export function estimateTokens(value) {
2
+ return Math.ceil(new TextEncoder().encode(JSON.stringify(value)).length / 3);
3
+ }
4
+ /** Summaries are the only layer that enters the prompt; details are fetched on demand. */
5
+ export function summaryItems(memories, tokenBudget, maxItems = 24) {
6
+ if (!Number.isFinite(tokenBudget) || tokenBudget <= 0)
7
+ return [];
8
+ const result = [];
9
+ const seen = new Set();
10
+ const limit = Math.min(24, Math.max(0, Math.floor(maxItems)));
11
+ for (const memory of memories) {
12
+ if (result.length >= limit)
13
+ break;
14
+ if (!memory.summary.trim())
15
+ continue;
16
+ const key = `${memory.memoryId}:${memory.revision}`;
17
+ if (seen.has(key))
18
+ continue;
19
+ const item = {
20
+ memoryId: memory.memoryId,
21
+ revision: memory.revision,
22
+ summary: memory.summary,
23
+ availableLayers: ['refined', 'raw'],
24
+ };
25
+ if (estimateTokens([...result, item]) > tokenBudget)
26
+ continue;
27
+ result.push(item);
28
+ seen.add(key);
29
+ }
30
+ return result;
31
+ }
32
+ /** Raw pages never truncate a source in the middle. */
33
+ export function rawPage(sources, offset = 0, pageSize = 4) {
34
+ const start = Math.max(0, Math.floor(offset));
35
+ const sourcesPage = sources.slice(start, start + pageSize);
36
+ const next = start + pageSize;
37
+ return next < sources.length ? { sources: [...sourcesPage], nextOffset: next } : { sources: [...sourcesPage] };
38
+ }
@@ -0,0 +1,11 @@
1
+ export { resolveStoreRoot, storePaths, type StorePaths } from './paths.js';
2
+ export { SCHEMA_VERSION, MAX_INDEX_LINES, MAX_INDEX_BYTES, NOTE_TYPES, STORE_LAYOUT, createEmptyMeta, formatNote, parseNote, noteFromRecord, validateColdRecord, type NoteType, type NoteState, type MaintenanceMode, type ColdRecord, type NoteFrontmatter, type Meta, } from './schema.js';
3
+ export { FileLockWriter, type Writer } from './writer.js';
4
+ export { MemoryStore, type StoredNote } from './store.js';
5
+ export { featuresOf, buildKeywordIndex, idf, relevance, type KeywordIndex } from './indexer.js';
6
+ export { recall, RECALL_WEIGHTS, ZONE_WEIGHTS, type RecallItem, type RecallHit, type RecallOptions, } from './recall.js';
7
+ export { estimateTokens, summaryItems, rawPage, type SummaryItem, type DisclosureCandidate } from './disclosure.js';
8
+ export { operationId, ReviewTiming, NoopReviewer, type ReviewRequest, type ReviewResult, type ReviewNote, type Reviewer, } from './review.js';
9
+ export { disabledLlm, isBudgetError, type LlmAdapter, type LlmRequest, type LlmResult, } from './llm-adapter.js';
10
+ export { LlmReviewer, type LlmReviewerOptions } from './llm-reviewer.js';
11
+ export { buildIndexSnapshot } from './context.js';
@@ -0,0 +1,11 @@
1
+ export { resolveStoreRoot, storePaths } from './paths.js';
2
+ export { SCHEMA_VERSION, MAX_INDEX_LINES, MAX_INDEX_BYTES, NOTE_TYPES, STORE_LAYOUT, createEmptyMeta, formatNote, parseNote, noteFromRecord, validateColdRecord, } from './schema.js';
3
+ export { FileLockWriter } from './writer.js';
4
+ export { MemoryStore } from './store.js';
5
+ export { featuresOf, buildKeywordIndex, idf, relevance } from './indexer.js';
6
+ export { recall, RECALL_WEIGHTS, ZONE_WEIGHTS, } from './recall.js';
7
+ export { estimateTokens, summaryItems, rawPage } from './disclosure.js';
8
+ export { operationId, ReviewTiming, NoopReviewer, } from './review.js';
9
+ export { disabledLlm, isBudgetError, } from './llm-adapter.js';
10
+ export { LlmReviewer } from './llm-reviewer.js';
11
+ export { buildIndexSnapshot } from './context.js';
@@ -0,0 +1,14 @@
1
+ /** English words plus Chinese character bigrams; no external tokenizer. */
2
+ export declare function featuresOf(text: string): string[];
3
+ export interface KeywordIndex {
4
+ docCount: number;
5
+ df: Record<string, number>;
6
+ postings: Record<string, string[]>;
7
+ }
8
+ export declare function buildKeywordIndex(docs: Array<{
9
+ id: string;
10
+ text: string;
11
+ }>): KeywordIndex;
12
+ export declare function idf(term: string, index: KeywordIndex): number;
13
+ /** IDF-weighted feature overlap, normalized so long notes cannot dominate. */
14
+ export declare function relevance(query: string, text: string, index: KeywordIndex): number;
@@ -0,0 +1,61 @@
1
+ const STOP_WORDS = new Set([
2
+ '现在', '可以', '这个', '那个', '什么', '怎么', '自己', '已经', '没有', '还是',
3
+ '就是', '如果', '因为', '所以', '但是', '然后', '今天', '明天', '时候', '一下',
4
+ '一个', '东西', '问题', '看看', '觉得', '应该', '需要', '不是', '我们', '你们',
5
+ '他们', '这样', '那样', '知道', '开始', '完成', '确认', '处理', '用户', '消息',
6
+ '任务', '结果', '内容', '情况', '部分', '方式', '功能', '文件',
7
+ ]);
8
+ const TOKEN_RE = /[A-Za-z][A-Za-z0-9_-]{2,}|[\u4e00-\u9fff]{2,6}/g;
9
+ /** English words plus Chinese character bigrams; no external tokenizer. */
10
+ export function featuresOf(text) {
11
+ const out = [];
12
+ for (const match of text.toLowerCase().match(TOKEN_RE) ?? []) {
13
+ if (/^[a-z]/.test(match)) {
14
+ if (!STOP_WORDS.has(match))
15
+ out.push(match);
16
+ continue;
17
+ }
18
+ if (match.length === 2) {
19
+ if (!STOP_WORDS.has(match))
20
+ out.push(match);
21
+ continue;
22
+ }
23
+ for (let i = 0; i < match.length - 1; i += 1) {
24
+ const gram = match.slice(i, i + 2);
25
+ if (!STOP_WORDS.has(gram))
26
+ out.push(gram);
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+ export function buildKeywordIndex(docs) {
32
+ const df = {};
33
+ const postings = {};
34
+ for (const doc of docs) {
35
+ for (const term of new Set(featuresOf(doc.text))) {
36
+ df[term] = (df[term] ?? 0) + 1;
37
+ (postings[term] ??= []).push(doc.id);
38
+ }
39
+ }
40
+ return { docCount: docs.length, df, postings };
41
+ }
42
+ export function idf(term, index) {
43
+ return Math.log((index.docCount + 1) / ((index.df[term] ?? 0) + 1)) + 1;
44
+ }
45
+ /** IDF-weighted feature overlap, normalized so long notes cannot dominate. */
46
+ export function relevance(query, text, index) {
47
+ const qTerms = [...new Set(featuresOf(query))];
48
+ const tTerms = new Set(featuresOf(text));
49
+ if (qTerms.length === 0 || tTerms.size === 0)
50
+ return 0;
51
+ let hit = 0;
52
+ let total = 0;
53
+ for (const term of qTerms) {
54
+ const weight = idf(term, index);
55
+ total += weight;
56
+ if (tTerms.has(term))
57
+ hit += weight;
58
+ }
59
+ const denominator = Math.sqrt(total * tTerms.size);
60
+ return denominator > 0 ? Math.min(1, hit / denominator) : 0;
61
+ }
@@ -0,0 +1,17 @@
1
+ export interface LlmRequest {
2
+ system: string;
3
+ messages: Array<{
4
+ role: 'user' | 'assistant';
5
+ content: string;
6
+ }>;
7
+ tools?: readonly unknown[];
8
+ }
9
+ export interface LlmResult {
10
+ text: string;
11
+ }
12
+ /** Implemented by the host layer; the core never owns credentials or a client. */
13
+ export interface LlmAdapter {
14
+ complete(request: LlmRequest): Promise<LlmResult>;
15
+ }
16
+ export declare const disabledLlm: LlmAdapter;
17
+ export declare function isBudgetError(error: unknown): boolean;
@@ -0,0 +1,8 @@
1
+ export const disabledLlm = {
2
+ async complete() {
3
+ throw new Error('LLM adapter is disabled (maintenanceMode: off)');
4
+ },
5
+ };
6
+ export function isBudgetError(error) {
7
+ return error?.code === 'MONEY_BUDGET_EXCEEDED';
8
+ }
@@ -0,0 +1,25 @@
1
+ import { type LlmAdapter } from './llm-adapter.js';
2
+ import { type ReviewRequest, type ReviewResult, type Reviewer } from './review.js';
3
+ import type { MemoryStore } from './store.js';
4
+ export interface LlmReviewerOptions {
5
+ companionId?: string;
6
+ candidateBudget?: number;
7
+ contextTokens?: number;
8
+ retryDelayMs?: number;
9
+ maxNotesPerBatch?: number;
10
+ }
11
+ /**
12
+ * Turns unprocessed `cold` lines into notes through the host model. The store is
13
+ * the single writer: notes, the bounded index and the watermark all go through it.
14
+ * A failed model call backs off instead of re-billing on every idle tick.
15
+ */
16
+ export declare class LlmReviewer implements Reviewer {
17
+ private readonly store;
18
+ private readonly adapter;
19
+ private readonly options;
20
+ private lastFailureAt;
21
+ constructor(store: MemoryStore, adapter: LlmAdapter, options?: LlmReviewerOptions);
22
+ review(request: ReviewRequest): Promise<ReviewResult>;
23
+ /** Reads the newest append-only files and keeps lines after the watermark. */
24
+ private pendingCandidates;
25
+ }
@@ -0,0 +1,160 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { estimateTokens } from './disclosure.js';
5
+ import { isBudgetError } from './llm-adapter.js';
6
+ import { operationId, } from './review.js';
7
+ const REVIEW_SYSTEM = `You are a long-term memory curator. From the conversation lines below, extract only durable facts about the user, their explicit preferences, feedback, or ongoing projects.
8
+ Return STRICT JSON only: {"notes":[{"name":"short-slug","description":"one sentence","type":"user|feedback|project|reference","evidence":"verbatim quote from the input","body":"the fact in plain words"}]}
9
+ Rules: one fact per note; evidence must be copied verbatim from the input; never invent or infer emotions; skip greetings, transient tasks, tool output and roleplay; return {"notes":[]} when nothing is worth keeping.`;
10
+ const NOTE_TYPES = ['user', 'feedback', 'project', 'reference'];
11
+ function operationMarker(body) {
12
+ return /<!-- op: ([a-f0-9]+) -->/.exec(body)?.[1];
13
+ }
14
+ function parseNotes(text) {
15
+ const start = text.indexOf('{');
16
+ const end = text.lastIndexOf('}');
17
+ if (start < 0 || end <= start)
18
+ return [];
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(text.slice(start, end + 1));
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ const raw = parsed?.notes;
27
+ if (!Array.isArray(raw))
28
+ return [];
29
+ return raw.flatMap((item) => {
30
+ if (!item || typeof item !== 'object')
31
+ return [];
32
+ const note = item;
33
+ const name = String(note.name ?? '').trim().slice(0, 48);
34
+ const description = String(note.description ?? '').trim().slice(0, 320);
35
+ const evidence = String(note.evidence ?? '').trim().slice(0, 2000);
36
+ const body = String(note.body ?? description).trim().slice(0, 12_000);
37
+ const type = NOTE_TYPES.includes(note.type) ? note.type : 'reference';
38
+ if (!name || !description || !body || !evidence)
39
+ return [];
40
+ return [{ name, description, type, evidence, sourceRefs: [], body }];
41
+ });
42
+ }
43
+ /**
44
+ * Turns unprocessed `cold` lines into notes through the host model. The store is
45
+ * the single writer: notes, the bounded index and the watermark all go through it.
46
+ * A failed model call backs off instead of re-billing on every idle tick.
47
+ */
48
+ export class LlmReviewer {
49
+ store;
50
+ adapter;
51
+ options;
52
+ lastFailureAt = 0;
53
+ constructor(store, adapter, options = {}) {
54
+ this.store = store;
55
+ this.adapter = adapter;
56
+ this.options = options;
57
+ }
58
+ async review(request) {
59
+ const empty = { notes: [], indexLines: [] };
60
+ const retryDelay = this.options.retryDelayMs ?? 60_000;
61
+ if (this.lastFailureAt > 0 && Date.now() - this.lastFailureAt < retryDelay)
62
+ return empty;
63
+ const meta = await this.store.readMeta();
64
+ const candidates = await this.pendingCandidates(meta.lastProcessedSeq);
65
+ const batch = candidates.slice(0, this.options.candidateBudget ?? 24);
66
+ if (batch.length === 0)
67
+ return empty;
68
+ const budget = this.options.contextTokens ?? 800;
69
+ const lines = [];
70
+ let used = 0;
71
+ for (const item of batch) {
72
+ const line = `[${item.role}] ${item.content.slice(0, 800)}`;
73
+ const cost = estimateTokens(line);
74
+ if (used + cost > budget)
75
+ break;
76
+ lines.push(line);
77
+ used += cost;
78
+ }
79
+ if (lines.length === 0)
80
+ return empty;
81
+ let answer;
82
+ try {
83
+ answer = (await this.adapter.complete({
84
+ system: REVIEW_SYSTEM,
85
+ messages: [{ role: 'user', content: lines.join('\n') }],
86
+ })).text;
87
+ }
88
+ catch (error) {
89
+ this.lastFailureAt = Date.now();
90
+ meta.lastReviewAt = Date.now();
91
+ await this.store.writeMeta(meta);
92
+ if (isBudgetError(error))
93
+ this.lastFailureAt += retryDelay;
94
+ return empty;
95
+ }
96
+ const parsed = parseNotes(answer).slice(0, this.options.maxNotesPerBatch ?? 8);
97
+ const companionId = request.companionId || this.options.companionId || 'default';
98
+ const existing = await this.store.listNotes();
99
+ const seen = new Set(existing.map((note) => operationMarker(note.body)).filter(Boolean));
100
+ const indexLines = [...await this.store.readIndex()];
101
+ const written = [];
102
+ const today = new Date().toISOString().slice(0, 10);
103
+ for (const note of parsed) {
104
+ const payloadHash = createHash('sha256').update(JSON.stringify(note)).digest('hex');
105
+ const opId = operationId(companionId, batch[0].id, batch[0].seq, payloadHash);
106
+ if (seen.has(opId))
107
+ continue;
108
+ const safeName = note.name.replace(/[\\/:*?"<>|\r\n]/g, '_');
109
+ const frontmatter = {
110
+ name: safeName,
111
+ description: note.description,
112
+ type: note.type,
113
+ created: today,
114
+ updated: today,
115
+ evidence: note.evidence,
116
+ sourceRefs: batch.slice(0, 4).map((item) => item.id),
117
+ supersededBy: null,
118
+ confidence: 0.7,
119
+ strength: 0.6,
120
+ lastAccess: Date.now(),
121
+ accessCount: 0,
122
+ pinned: false,
123
+ state: 'active',
124
+ };
125
+ const id = await this.store.writeNote(frontmatter, `${note.body}\n\n<!-- op: ${opId} -->`);
126
+ const line = `- [${safeName}](notes/${id}) — ${note.description.slice(0, 80)}`;
127
+ if (!indexLines.includes(line))
128
+ indexLines.push(line);
129
+ written.push(note);
130
+ seen.add(opId);
131
+ }
132
+ if (written.length > 0)
133
+ await this.store.writeIndex(indexLines);
134
+ meta.lastProcessedSeq = Math.max(meta.lastProcessedSeq, ...batch.map((item) => item.seq));
135
+ meta.lastReviewAt = Date.now();
136
+ await this.store.writeMeta(meta);
137
+ return { notes: written, indexLines };
138
+ }
139
+ /** Reads the newest append-only files and keeps lines after the watermark. */
140
+ async pendingCandidates(afterSeq) {
141
+ const files = await readdir(this.store.paths.cold).catch(() => []);
142
+ const found = [];
143
+ for (const file of files.filter((name) => name.endsWith('.jsonl')).sort().slice(-3)) {
144
+ const text = await readFile(join(this.store.paths.cold, file), 'utf8').catch(() => '');
145
+ for (const line of text.split(/\r?\n/).filter(Boolean)) {
146
+ try {
147
+ const record = JSON.parse(line);
148
+ if (typeof record.seq === 'number' && record.seq > afterSeq
149
+ && typeof record.content === 'string' && record.content.trim()) {
150
+ found.push(record);
151
+ }
152
+ }
153
+ catch {
154
+ continue;
155
+ }
156
+ }
157
+ }
158
+ return found.sort((a, b) => a.seq - b.seq);
159
+ }
160
+ }