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.
- package/LICENSE +21 -0
- package/README.md +101 -3
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/cordis.patch.yml +18 -0
- package/icon.png +0 -0
- package/lib/README.md +3 -0
- package/lib/client/index.d.ts +8 -0
- package/lib/client/index.js +9 -0
- package/lib/client/panel.d.ts +21 -0
- package/lib/client/panel.js +36 -0
- package/lib/core/context.d.ts +6 -0
- package/lib/core/context.js +14 -0
- package/lib/core/disclosure.d.ts +20 -0
- package/lib/core/disclosure.js +38 -0
- package/lib/core/index.d.ts +11 -0
- package/lib/core/index.js +11 -0
- package/lib/core/indexer.d.ts +14 -0
- package/lib/core/indexer.js +61 -0
- package/lib/core/llm-adapter.d.ts +17 -0
- package/lib/core/llm-adapter.js +8 -0
- package/lib/core/llm-reviewer.d.ts +25 -0
- package/lib/core/llm-reviewer.js +160 -0
- package/lib/core/paths.d.ts +17 -0
- package/lib/core/paths.js +30 -0
- package/lib/core/recall.d.ts +37 -0
- package/lib/core/recall.js +38 -0
- package/lib/core/review.d.ts +39 -0
- package/lib/core/review.js +41 -0
- package/lib/core/schema.d.ts +57 -0
- package/lib/core/schema.js +96 -0
- package/lib/core/store.d.ts +28 -0
- package/lib/core/store.js +89 -0
- package/lib/core/writer.d.ts +11 -0
- package/lib/core/writer.js +47 -0
- package/lib/host/dsh/bindings.d.ts +15 -0
- package/lib/host/dsh/bindings.js +28 -0
- package/lib/host/dsh/contracts.d.ts +84 -0
- package/lib/host/dsh/contracts.js +1 -0
- package/lib/host/dsh/events.d.ts +13 -0
- package/lib/host/dsh/events.js +91 -0
- package/lib/host/dsh/llm.d.ts +19 -0
- package/lib/host/dsh/llm.js +52 -0
- package/lib/host/dsh/plugin.d.ts +34 -0
- package/lib/host/dsh/plugin.js +239 -0
- package/lib/host/dsh/prompt.d.ts +4 -0
- package/lib/host/dsh/prompt.js +10 -0
- package/lib/host/dsh/timing.d.ts +13 -0
- package/lib/host/dsh/timing.js +29 -0
- package/lib/host/dsh/tools.d.ts +10 -0
- package/lib/host/dsh/tools.js +51 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- 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
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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.
|
package/cordis.patch.yml
ADDED
|
@@ -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,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,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
|
+
}
|