dsh-trivium 0.4.21 → 0.5.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/README.md +49 -6
- package/README.zh-CN.md +49 -6
- package/lib/client.js +948 -9
- package/lib/context-budget.js +348 -0
- package/lib/extract.js +25 -11
- package/lib/index.js +236 -63
- package/lib/markdown.js +0 -2
- package/lib/pins.js +66 -21
- package/lib/provenance.js +147 -0
- package/lib/recall-preview.js +128 -0
- package/lib/recall-trace.js +285 -0
- package/lib/revision-graph.js +103 -0
- package/lib/revisions.js +451 -0
- package/lib/schema.js +13 -1
- package/lib/server.js +204 -4
- package/lib/sidecar.js +122 -15
- package/lib/store.js +480 -62
- package/lib/tool-defs.js +93 -0
- package/lib/tool-output.js +88 -0
- package/lib/tools.js +69 -43
- package/lib/turn-ledger.js +184 -0
- package/lib/until.js +1 -13
- package/package.json +10 -4
package/lib/tool-defs.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four tool definitions, kept as plain data with no @deepseek-ai imports.
|
|
3
|
+
*
|
|
4
|
+
* Two reasons this is a separate module:
|
|
5
|
+
* - the offline suite can serialise the exact registered projection and compare
|
|
6
|
+
* it against docs/next-memory/TOOL_SCHEMA_BASELINE.json, and
|
|
7
|
+
* - the contract is "no new tools, no new model-facing parameters", which is
|
|
8
|
+
* easier to audit when the surface is one frozen array.
|
|
9
|
+
*
|
|
10
|
+
* Source/history/supersede details are read through the management UI, never
|
|
11
|
+
* through new tool parameters.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { estimateTokens } from "./tokens.js";
|
|
15
|
+
|
|
16
|
+
export const TOOL_DEFS = Object.freeze([
|
|
17
|
+
{
|
|
18
|
+
name: "ctx_find",
|
|
19
|
+
description:
|
|
20
|
+
"Search local Trivium graph memory by keywords, meaning (when embeddings are enabled), and entity relationships. Relevant expired decisions remain searchable, marked stale and ranked lower; treat them as historical, not current instructions. Set include_stale=false for current facts only. Returns L0 summaries and edge paths. Call ctx_read for full text.",
|
|
21
|
+
parameters: {
|
|
22
|
+
query: { type: "string", required: true, description: "Search query." },
|
|
23
|
+
top_k: { type: "integer", description: "Max hits, 1-20." },
|
|
24
|
+
include_stale: {
|
|
25
|
+
type: "boolean",
|
|
26
|
+
description: "Include relevant expired memories (default true). Expired hits are marked stale.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "ctx_read",
|
|
32
|
+
description:
|
|
33
|
+
"Read one memory node by numeric id returned from ctx_find. Includes incoming about/decided/broke/fixed edges.",
|
|
34
|
+
parameters: {
|
|
35
|
+
id: { type: "integer", required: true, description: "Node id." },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "ctx_remember",
|
|
40
|
+
description:
|
|
41
|
+
"Store a durable fact as an entity, preference, decision, or experience. Do not store secrets or one-off task instructions.",
|
|
42
|
+
parameters: {
|
|
43
|
+
text: { type: "string", required: true, description: "Fact to store." },
|
|
44
|
+
type: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "entity | preference | decision | experience",
|
|
47
|
+
},
|
|
48
|
+
name: { type: "string", description: "Short name for entities." },
|
|
49
|
+
until: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description:
|
|
52
|
+
"Optional explicit expiry, only when the user states this fact expires: YYYY-MM-DD, weekday (Friday / 周五), or 下周. A date mentioned in the fact is not an expiry.",
|
|
53
|
+
},
|
|
54
|
+
link_to: { type: "integer", description: "Optional existing node id to link." },
|
|
55
|
+
link_label: { type: "string", description: "Edge label when link_to is set." },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "ctx_link",
|
|
60
|
+
description: "Create a directed edge between two memory node ids.",
|
|
61
|
+
parameters: {
|
|
62
|
+
from: { type: "integer", required: true, description: "Source node id." },
|
|
63
|
+
to: { type: "integer", required: true, description: "Target node id." },
|
|
64
|
+
label: { type: "string", description: "Edge label, default about." },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Canonical name/description/parameters projection, in declaration order.
|
|
71
|
+
*
|
|
72
|
+
* This is the same shape as TOOL_SCHEMA_BASELINE.json: parameters is an object
|
|
73
|
+
* schema, and `required` collapses the per-parameter `required: true` flags.
|
|
74
|
+
* It is a relative size check, not the host wire format.
|
|
75
|
+
*/
|
|
76
|
+
export function schemaProjection(defs = TOOL_DEFS) {
|
|
77
|
+
return defs.map((def) => {
|
|
78
|
+
const properties = {};
|
|
79
|
+
const required = [];
|
|
80
|
+
for (const [key, spec] of Object.entries(def.parameters || {})) {
|
|
81
|
+
properties[key] = { type: spec.type, description: spec.description };
|
|
82
|
+
if (spec.required === true) required.push(key);
|
|
83
|
+
}
|
|
84
|
+
const parameters = { type: "object", properties };
|
|
85
|
+
if (required.length) parameters.required = required;
|
|
86
|
+
return { name: def.name, description: def.description, parameters };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Estimated tokens for the whole projection, using the plugin's own heuristic. */
|
|
91
|
+
export function estimatedSchemaTokens(defs = TOOL_DEFS) {
|
|
92
|
+
return estimateTokens(JSON.stringify(schemaProjection(defs)));
|
|
93
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool output budgets.
|
|
3
|
+
*
|
|
4
|
+
* `ctx_find` used to return whatever the search produced. Model context is the
|
|
5
|
+
* scarce resource, so the default output is now capped and whole records are
|
|
6
|
+
* dropped when they do not fit — never sliced, so an id is never half-written.
|
|
7
|
+
*
|
|
8
|
+
* All numbers are `estimateTokens` results (CJK ≈ 1, ASCII ≈ 1/4) and must be
|
|
9
|
+
* presented as estimates, not model tokens or billing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { estimateTokens } from "./tokens.js";
|
|
13
|
+
|
|
14
|
+
/** Default total for one ctx_find call, truncation notice included. */
|
|
15
|
+
export const CTX_FIND_OUTPUT_BUDGET = 1200;
|
|
16
|
+
|
|
17
|
+
/** Payload keys ctx_read may echo. New provenance/trace fields stay out. */
|
|
18
|
+
export const CTX_READ_PAYLOAD_KEYS = Object.freeze([
|
|
19
|
+
"type",
|
|
20
|
+
"name",
|
|
21
|
+
"text",
|
|
22
|
+
"until",
|
|
23
|
+
"untilAt",
|
|
24
|
+
"status",
|
|
25
|
+
"uri",
|
|
26
|
+
"aliases",
|
|
27
|
+
"fail",
|
|
28
|
+
"fix",
|
|
29
|
+
"createdAt",
|
|
30
|
+
"updatedAt",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Project a payload for tool output.
|
|
35
|
+
*
|
|
36
|
+
* The node payload also carries provenance, revision chains and other UI-only
|
|
37
|
+
* metadata. Serialising the payload wholesale made ctx_read grow every time a
|
|
38
|
+
* UI feature added a field, so the default projection is an explicit allowlist.
|
|
39
|
+
* `source` keeps only the session id it had before this change.
|
|
40
|
+
*/
|
|
41
|
+
export function projectPayloadForTool(payload) {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const key of CTX_READ_PAYLOAD_KEYS) {
|
|
44
|
+
const value = payload?.[key];
|
|
45
|
+
if (value === undefined || value === null || value === "") continue;
|
|
46
|
+
out[key] = value;
|
|
47
|
+
}
|
|
48
|
+
const sessionId = payload?.source?.sessionId;
|
|
49
|
+
if (sessionId) out.source = { sessionId: String(sessionId) };
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultNotice(omitted) {
|
|
54
|
+
return `… ${omitted} more hit(s) omitted to keep this result within ~${CTX_FIND_OUTPUT_BUDGET} estimated tokens. Narrow the query or raise top_k.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Keep whole blocks while the rendered result, notice included, fits the budget.
|
|
59
|
+
*
|
|
60
|
+
* @returns text, how many blocks were kept, how many were omitted, estimate.
|
|
61
|
+
*/
|
|
62
|
+
export function packTextBlocks(blocks, { budget = CTX_FIND_OUTPUT_BUDGET, separator = "\n\n", noticeFor = defaultNotice } = {}) {
|
|
63
|
+
const list = (blocks || []).filter((block) => typeof block === "string" && block.length > 0);
|
|
64
|
+
if (!list.length) return { text: "", kept: 0, omitted: 0, tokens: 0, budget, truncated: false };
|
|
65
|
+
const notice = (omitted) => (typeof noticeFor === "function" ? noticeFor(omitted) : defaultNotice(omitted));
|
|
66
|
+
const kept = [];
|
|
67
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
68
|
+
const attempt = [...kept, list[i]];
|
|
69
|
+
const rest = list.length - attempt.length;
|
|
70
|
+
const text = rest > 0 ? [...attempt, notice(rest)].join(separator) : attempt.join(separator);
|
|
71
|
+
if (estimateTokens(text) <= budget) {
|
|
72
|
+
kept.push(list[i]);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const omitted = list.length - i;
|
|
76
|
+
const rendered = [...kept, notice(omitted)].join(separator);
|
|
77
|
+
return {
|
|
78
|
+
text: rendered,
|
|
79
|
+
kept: kept.length,
|
|
80
|
+
omitted,
|
|
81
|
+
tokens: estimateTokens(rendered),
|
|
82
|
+
budget,
|
|
83
|
+
truncated: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const text = kept.join(separator);
|
|
87
|
+
return { text, kept: kept.length, omitted: 0, tokens: estimateTokens(text), budget, truncated: false };
|
|
88
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
2
2
|
import { EDGE_LABELS } from "./schema.js";
|
|
3
3
|
import { sanitizeForWrite, writeGateMessage } from "./hygiene.js";
|
|
4
|
+
import { TOOL_DEFS } from "./tool-defs.js";
|
|
5
|
+
import { makeRevisionResolver } from "./revisions.js";
|
|
6
|
+
import { isStalePayload } from "./until.js";
|
|
7
|
+
import { CTX_FIND_OUTPUT_BUDGET, packTextBlocks, projectPayloadForTool } from "./tool-output.js";
|
|
4
8
|
import {
|
|
5
9
|
dbPathFor,
|
|
6
10
|
embedQueryVector,
|
|
@@ -36,6 +40,11 @@ function textTool(definition) {
|
|
|
36
40
|
});
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
/** Definition lookup by name; the array order is the registration order. */
|
|
44
|
+
function defOf(name) {
|
|
45
|
+
return TOOL_DEFS.find((def) => def.name === name);
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
export function cwdOf(agentOrExec) {
|
|
40
49
|
const agent = agentOrExec?.agent ?? agentOrExec;
|
|
41
50
|
return (
|
|
@@ -57,13 +66,7 @@ export function registerTools(ctx, optionsOrFn) {
|
|
|
57
66
|
() =>
|
|
58
67
|
ctx.tools.register(
|
|
59
68
|
textTool({
|
|
60
|
-
|
|
61
|
-
description:
|
|
62
|
-
"Search local Trivium graph memory. If the query names a known entity, also returns its unexpired about/decided/broke/fixed neighbors. Returns L0 summaries plus edge paths (incoming as <-label-id). Call ctx_read for full text. Do not paste find results verbatim into later thinking.",
|
|
63
|
-
parameters: {
|
|
64
|
-
query: { type: "string", required: true, description: "Search query." },
|
|
65
|
-
top_k: { type: "integer", description: "Max hits, 1-20." },
|
|
66
|
-
},
|
|
69
|
+
...defOf("ctx_find"),
|
|
67
70
|
async execute(args, exec) {
|
|
68
71
|
try {
|
|
69
72
|
const options = optionsOf();
|
|
@@ -74,21 +77,51 @@ export function registerTools(ctx, optionsOrFn) {
|
|
|
74
77
|
topK: k,
|
|
75
78
|
expandDepth: options.expandDepth ?? 1,
|
|
76
79
|
queryVector: queryVector || undefined,
|
|
80
|
+
// `include_stale` stays a deadline-only switch: revision history
|
|
81
|
+
// is a separate axis and is never expanded by this flag.
|
|
82
|
+
includeStale: args.include_stale !== false,
|
|
77
83
|
});
|
|
78
84
|
if (!hits.length) return "No memory hits.";
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
/*
|
|
86
|
+
* A keyword hit on an old version is answered with the current
|
|
87
|
+
* head: mapped, de-duplicated, and carrying one short pointer so
|
|
88
|
+
* the model knows a newer decision exists. The old body stays
|
|
89
|
+
* readable through ctx_read(id).
|
|
90
|
+
*/
|
|
91
|
+
const resolveCurrent = makeRevisionResolver(db);
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
const candidates = [];
|
|
94
|
+
const mapped = new Map();
|
|
81
95
|
for (const hit of hits) {
|
|
82
96
|
const row = formatHit(db, hit);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
97
|
+
const resolved = resolveCurrent(row.id);
|
|
98
|
+
if (!resolved) continue;
|
|
99
|
+
const targetId = Number(resolved.currentId);
|
|
100
|
+
if (targetId !== row.id) {
|
|
101
|
+
// A superseded chain whose head is archived or expired is not
|
|
102
|
+
// resurrected as its old version either.
|
|
103
|
+
if (resolved.archived || resolved.stale) continue;
|
|
104
|
+
mapped.set(targetId, { from: row.id, current: targetId });
|
|
86
105
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
);
|
|
106
|
+
if (seen.has(targetId)) continue;
|
|
107
|
+
seen.add(targetId);
|
|
108
|
+
candidates.push({ row, targetId, matchedHistory: mapped.get(targetId) || null });
|
|
90
109
|
}
|
|
91
|
-
return
|
|
110
|
+
if (!candidates.length) return "No memory hits.";
|
|
111
|
+
const blocks = candidates.map(({ row, targetId, matchedHistory }) => {
|
|
112
|
+
const node = targetId === row.id ? null : db.get(targetId);
|
|
113
|
+
const payload = node?.payload || {};
|
|
114
|
+
const type = targetId === row.id ? row.type : payload.type || "unknown";
|
|
115
|
+
const body = targetId === row.id ? row.l0 : String(payload.text || payload.name || "").slice(0, 200);
|
|
116
|
+
const until = targetId === row.id ? row.until : payload.until || "";
|
|
117
|
+
const stale = targetId === row.id ? row.stale : isStalePayload(payload);
|
|
118
|
+
const pointer = matchedHistory
|
|
119
|
+
? `\nmatched_history=#${matchedHistory.from} → current=#${matchedHistory.current}`
|
|
120
|
+
: "";
|
|
121
|
+
return `[${row.score.toFixed(3)}] id=${targetId} type=${type}${until ? ` until=${until}` : ""}${stale ? " stale" : ""}\n${body}\npath: ${row.path.slice(0, 2).join(" | ") || "(none)"}${pointer}`;
|
|
122
|
+
});
|
|
123
|
+
const packed = packTextBlocks(blocks, { budget: CTX_FIND_OUTPUT_BUDGET });
|
|
124
|
+
return packed.text;
|
|
92
125
|
} catch (err) {
|
|
93
126
|
return `ctx_find unavailable: ${err.message}`;
|
|
94
127
|
}
|
|
@@ -102,20 +135,22 @@ export function registerTools(ctx, optionsOrFn) {
|
|
|
102
135
|
() =>
|
|
103
136
|
ctx.tools.register(
|
|
104
137
|
textTool({
|
|
105
|
-
|
|
106
|
-
description: "Read one memory node by numeric id returned from ctx_find. Includes incoming about/decided/broke/fixed edges.",
|
|
107
|
-
parameters: {
|
|
108
|
-
id: { type: "integer", required: true, description: "Node id." },
|
|
109
|
-
},
|
|
138
|
+
...defOf("ctx_read"),
|
|
110
139
|
async execute(args, exec) {
|
|
111
140
|
try {
|
|
112
141
|
const db = await dbOf(exec);
|
|
113
142
|
const node = db.get(Number(args.id));
|
|
114
143
|
if (!node) return `Not found: ${args.id}`;
|
|
144
|
+
/*
|
|
145
|
+
* Explicit full read: the body is never truncated. Only the
|
|
146
|
+
* payload projection is narrowed, so provenance/revision metadata
|
|
147
|
+
* cannot grow this result. Sources and history are read from the
|
|
148
|
+
* management UI.
|
|
149
|
+
*/
|
|
115
150
|
return JSON.stringify(
|
|
116
151
|
{
|
|
117
152
|
id: node.id,
|
|
118
|
-
payload: node.payload,
|
|
153
|
+
payload: projectPayloadForTool(node.payload),
|
|
119
154
|
edges: node.edges,
|
|
120
155
|
incoming: listIncomingBusiness(db, node.id),
|
|
121
156
|
},
|
|
@@ -135,19 +170,7 @@ export function registerTools(ctx, optionsOrFn) {
|
|
|
135
170
|
() =>
|
|
136
171
|
ctx.tools.register(
|
|
137
172
|
textTool({
|
|
138
|
-
|
|
139
|
-
description:
|
|
140
|
-
"Store a durable fact as an entity, preference, decision, or experience. Do not store secrets or one-off task instructions.",
|
|
141
|
-
parameters: {
|
|
142
|
-
text: { type: "string", required: true, description: "Fact to store." },
|
|
143
|
-
type: {
|
|
144
|
-
type: "string",
|
|
145
|
-
description: "entity | preference | decision | experience",
|
|
146
|
-
},
|
|
147
|
-
name: { type: "string", description: "Short name for entities." },
|
|
148
|
-
link_to: { type: "integer", description: "Optional existing node id to link." },
|
|
149
|
-
link_label: { type: "string", description: "Edge label when link_to is set." },
|
|
150
|
-
},
|
|
173
|
+
...defOf("ctx_remember"),
|
|
151
174
|
async execute(args, exec) {
|
|
152
175
|
try {
|
|
153
176
|
const gate = sanitizeForWrite(args.text);
|
|
@@ -162,8 +185,12 @@ export function registerTools(ctx, optionsOrFn) {
|
|
|
162
185
|
type,
|
|
163
186
|
name: args.name || undefined,
|
|
164
187
|
text: args.text,
|
|
188
|
+
...(args.until !== undefined ? { until: args.until } : {}),
|
|
165
189
|
uri: `ctx://${type}/${Date.now()}`,
|
|
166
|
-
source: {
|
|
190
|
+
source: {
|
|
191
|
+
kind: "tool",
|
|
192
|
+
sessionId: String(exec?.agent?.session?.header?.id || exec?.agent?.id || ""),
|
|
193
|
+
},
|
|
167
194
|
};
|
|
168
195
|
const existing = findMergeTarget(db, incoming);
|
|
169
196
|
const id =
|
|
@@ -186,17 +213,16 @@ export function registerTools(ctx, optionsOrFn) {
|
|
|
186
213
|
() =>
|
|
187
214
|
ctx.tools.register(
|
|
188
215
|
textTool({
|
|
189
|
-
|
|
190
|
-
description: "Create a directed edge between two memory node ids.",
|
|
191
|
-
parameters: {
|
|
192
|
-
from: { type: "integer", required: true, description: "Source node id." },
|
|
193
|
-
to: { type: "integer", required: true, description: "Target node id." },
|
|
194
|
-
label: { type: "string", description: "Edge label, default about." },
|
|
195
|
-
},
|
|
216
|
+
...defOf("ctx_link"),
|
|
196
217
|
async execute(args, exec) {
|
|
197
218
|
try {
|
|
198
219
|
const db = await dbOf(exec);
|
|
199
220
|
const label = args.label || EDGE_LABELS.about;
|
|
221
|
+
// Revision relations are confirmed in the management UI only, so
|
|
222
|
+
// a model cannot invent a supersedes chain here.
|
|
223
|
+
const { refuseModelRevisionLabel } = await import("./revisions.js");
|
|
224
|
+
const refused = refuseModelRevisionLabel(label);
|
|
225
|
+
if (refused) return refused;
|
|
200
226
|
ensureLink(db, Number(args.from), Number(args.to), label);
|
|
201
227
|
return `Linked ${args.from} -[${label}]-> ${args.to}`;
|
|
202
228
|
} catch (err) {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn delivery ledger.
|
|
3
|
+
*
|
|
4
|
+
* The plugin can be asked to plan the same user turn several times (one per
|
|
5
|
+
* tool step). Without a ledger the same pins/recall text is injected again on
|
|
6
|
+
* every step. The ledger records what was actually accepted for this turn, so
|
|
7
|
+
* the second step sees "already delivered" instead of re-sending.
|
|
8
|
+
*
|
|
9
|
+
* Rules this file enforces:
|
|
10
|
+
* - A turn is identified by host ids only (see turnKeyOf); never by clock or text.
|
|
11
|
+
* - Only delivered records are recorded; budget-evicted ones are not.
|
|
12
|
+
* - A failed send leaves nothing behind, so the next attempt can retry.
|
|
13
|
+
* - Compaction clears the turn so necessary context can be rebuilt; a fork is a
|
|
14
|
+
* different session id and therefore inherits nothing.
|
|
15
|
+
*
|
|
16
|
+
* State is in-memory by design: it describes this process's view of one turn.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { turnKeyOf } from "./context-budget.js";
|
|
20
|
+
|
|
21
|
+
const sessions = new Map();
|
|
22
|
+
|
|
23
|
+
function stateOf(sessionId) {
|
|
24
|
+
const key = String(sessionId || "");
|
|
25
|
+
let state = sessions.get(key);
|
|
26
|
+
if (!state) {
|
|
27
|
+
state = {
|
|
28
|
+
sessionId: key,
|
|
29
|
+
turnKey: "",
|
|
30
|
+
delivered: new Set(),
|
|
31
|
+
spent: 0,
|
|
32
|
+
mapText: "",
|
|
33
|
+
mapTurnKey: "",
|
|
34
|
+
corrections: [],
|
|
35
|
+
generation: 0,
|
|
36
|
+
};
|
|
37
|
+
sessions.set(key, state);
|
|
38
|
+
}
|
|
39
|
+
return state;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Start (or continue) a turn. A new turn key clears the delivered set and the
|
|
44
|
+
* spent counter; queued corrections survive because they are still pending.
|
|
45
|
+
*/
|
|
46
|
+
export function beginTurn(sessionId, turnKey) {
|
|
47
|
+
const state = stateOf(sessionId);
|
|
48
|
+
const key = String(turnKey || "");
|
|
49
|
+
if (state.turnKey !== key) {
|
|
50
|
+
state.turnKey = key;
|
|
51
|
+
state.delivered = new Set();
|
|
52
|
+
state.spent = 0;
|
|
53
|
+
state.mapText = "";
|
|
54
|
+
state.mapTurnKey = "";
|
|
55
|
+
}
|
|
56
|
+
return state;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function deliveredOf(sessionId, turnKey) {
|
|
60
|
+
const state = beginTurn(sessionId, turnKey);
|
|
61
|
+
return state.delivered;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function spentOf(sessionId, turnKey) {
|
|
65
|
+
return beginTurn(sessionId, turnKey).spent;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Budget still available in this turn (never negative). */
|
|
69
|
+
export function remainingOf(sessionId, turnKey, budget) {
|
|
70
|
+
const cap = Math.max(0, Number(budget) || 0);
|
|
71
|
+
return Math.max(0, cap - spentOf(sessionId, turnKey));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Remember the map text when it landed in the same turn as the records. */
|
|
75
|
+
export function noteMap(sessionId, turnKey, text) {
|
|
76
|
+
const state = beginTurn(sessionId, turnKey);
|
|
77
|
+
state.mapText = String(text || "");
|
|
78
|
+
state.mapTurnKey = String(turnKey || "");
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function mapTextOf(sessionId, turnKey) {
|
|
83
|
+
const state = beginTurn(sessionId, turnKey);
|
|
84
|
+
return state.mapTurnKey === String(turnKey || "") ? state.mapText : "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Queue a correction (superseded/archived/expired after this turn's send). */
|
|
88
|
+
export function queueCorrection(sessionId, correction) {
|
|
89
|
+
const state = stateOf(sessionId);
|
|
90
|
+
const id = Number(correction?.id);
|
|
91
|
+
if (!Number.isFinite(id)) return state.corrections;
|
|
92
|
+
if (state.corrections.some((c) => Number(c.id) === id && c.note === correction.note)) return state.corrections;
|
|
93
|
+
state.corrections.push({ ...correction, id, queuedAt: Date.now() });
|
|
94
|
+
if (state.corrections.length > 20) state.corrections = state.corrections.slice(-20);
|
|
95
|
+
return state.corrections;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function pendingCorrections(sessionId) {
|
|
99
|
+
return [...stateOf(sessionId).corrections];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reserve a delivery. The reservation is only a claim; call {@link commit} once
|
|
104
|
+
* the messages were handed to the host, or {@link rollback} if anything threw.
|
|
105
|
+
* `delivered` is deliberately untouched here, so a retry still sees its content.
|
|
106
|
+
*/
|
|
107
|
+
export function reserve(sessionId, turnKey, records = []) {
|
|
108
|
+
const state = beginTurn(sessionId, turnKey);
|
|
109
|
+
const list = (records || []).filter((r) => Number.isFinite(Number(r?.id)));
|
|
110
|
+
return {
|
|
111
|
+
sessionId: state.sessionId,
|
|
112
|
+
turnKey: state.turnKey,
|
|
113
|
+
generation: state.generation,
|
|
114
|
+
records: list.map((r) => ({
|
|
115
|
+
id: Number(r.id),
|
|
116
|
+
channel: r.channel || "recall",
|
|
117
|
+
fingerprint: String(r.fingerprint || ""),
|
|
118
|
+
tokens: Number(r.tokens) || 0,
|
|
119
|
+
})),
|
|
120
|
+
tokens: list.reduce((sum, r) => sum + (Number(r.tokens) || 0), 0),
|
|
121
|
+
committed: false,
|
|
122
|
+
rolledBack: false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Mark the reservation delivered. Idempotent; ignores stale generations. */
|
|
127
|
+
export function commit(reservation) {
|
|
128
|
+
if (!reservation || reservation.rolledBack) return reservation;
|
|
129
|
+
const state = stateOf(reservation.sessionId);
|
|
130
|
+
if (state.turnKey !== reservation.turnKey || state.generation !== reservation.generation) {
|
|
131
|
+
return reservation;
|
|
132
|
+
}
|
|
133
|
+
for (const record of reservation.records) {
|
|
134
|
+
if (record.fingerprint) state.delivered.add(record.fingerprint);
|
|
135
|
+
state.spent += record.tokens;
|
|
136
|
+
}
|
|
137
|
+
reservation.committed = true;
|
|
138
|
+
return reservation;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Undo an uncommitted reservation. Nothing was marked, so a retry re-sends. */
|
|
142
|
+
export function rollback(reservation) {
|
|
143
|
+
if (!reservation || reservation.committed) return reservation;
|
|
144
|
+
reservation.rolledBack = true;
|
|
145
|
+
return reservation;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Compaction rebuild: drop this turn's delivered set and the spent counter so
|
|
150
|
+
* the next turn may re-establish necessary context, and bump the generation so
|
|
151
|
+
* an in-flight reservation cannot commit against the new state.
|
|
152
|
+
*/
|
|
153
|
+
export function clearAfterCompaction(sessionId) {
|
|
154
|
+
const state = stateOf(sessionId);
|
|
155
|
+
state.turnKey = "";
|
|
156
|
+
state.delivered = new Set();
|
|
157
|
+
state.spent = 0;
|
|
158
|
+
state.mapText = "";
|
|
159
|
+
state.mapTurnKey = "";
|
|
160
|
+
state.generation += 1;
|
|
161
|
+
return state;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function clearSession(sessionId) {
|
|
165
|
+
sessions.delete(String(sessionId || ""));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function snapshot(sessionId) {
|
|
169
|
+
const state = stateOf(sessionId);
|
|
170
|
+
return {
|
|
171
|
+
sessionId: state.sessionId,
|
|
172
|
+
turnKey: state.turnKey,
|
|
173
|
+
generation: state.generation,
|
|
174
|
+
delivered: [...state.delivered],
|
|
175
|
+
spent: state.spent,
|
|
176
|
+
corrections: state.corrections.map((c) => ({ ...c })),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resetAll() {
|
|
181
|
+
sessions.clear();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { turnKeyOf };
|
package/lib/until.js
CHANGED
|
@@ -10,19 +10,7 @@ const WEEKDAYS = {
|
|
|
10
10
|
saturday: 6,
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
export function parseUntilFromText(text) {
|
|
15
|
-
const s = String(text || "");
|
|
16
|
-
if (/下周/.test(s)) return "下周";
|
|
17
|
-
const day = s.match(/\buntil\s+(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b/i);
|
|
18
|
-
if (day) return day[1];
|
|
19
|
-
const iso = s.match(/\b(\d{4}-\d{2}-\d{2})\b/);
|
|
20
|
-
if (iso) return iso[1];
|
|
21
|
-
const cn = s.match(/到?(\d{1,2}\s*月\d{1,2}\s*日|周五|周末)/);
|
|
22
|
-
if (cn) return cn[1];
|
|
23
|
-
return undefined;
|
|
24
|
-
}
|
|
25
|
-
|
|
13
|
+
// Only resolve an explicit expiry field. Dates mentioned in prose are not expiry.
|
|
26
14
|
export function resolveUntilAt(until, from = new Date()) {
|
|
27
15
|
const raw = String(until || "").trim();
|
|
28
16
|
if (!raw) return undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-trivium",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "In-process graph memory for DeepSeek Harness, backed by TriviumDB.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,6 +48,12 @@
|
|
|
48
48
|
"smoke-p12": "node scripts/smoke-p12.mjs",
|
|
49
49
|
"smoke-p13": "node scripts/smoke-p13.mjs",
|
|
50
50
|
"smoke-p14": "node scripts/smoke-p14.mjs",
|
|
51
|
+
"smoke-p15": "node scripts/smoke-p15.mjs",
|
|
52
|
+
"smoke-p16": "node scripts/smoke-p16.mjs",
|
|
53
|
+
"smoke-p17": "node scripts/smoke-p17.mjs",
|
|
54
|
+
"smoke-p18": "node scripts/smoke-p18.mjs",
|
|
55
|
+
"smoke-p19": "node scripts/smoke-p19.mjs",
|
|
56
|
+
"smoke-p20": "node scripts/smoke-p20.mjs",
|
|
51
57
|
"preuninstall": "node ./lib/purge.js",
|
|
52
58
|
"verify-live-p2": "node scripts/verify-live-p2.mjs"
|
|
53
59
|
},
|
|
@@ -55,11 +61,11 @@
|
|
|
55
61
|
"node": "^22.19.0 || >=24"
|
|
56
62
|
},
|
|
57
63
|
"peerDependencies": {
|
|
58
|
-
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.8 <0.1.1 || >=0.1.1-rc.2 <0.3.0",
|
|
59
|
-
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.8 <0.1.1 || >=0.1.1-rc.2 <0.3.0"
|
|
64
|
+
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.8 <0.1.1 || >=0.1.1-rc.2 <0.3.0 || >=0.1.5-rc.1 <0.1.6 || >=0.2.0-rc.0 <0.2.1 || >=0.2.1-rc.0 <0.3.0",
|
|
65
|
+
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.8 <0.1.1 || >=0.1.1-rc.2 <0.3.0 || >=0.1.5-rc.1 <0.1.6 || >=0.2.0-rc.0 <0.2.1 || >=0.2.1-rc.0 <0.3.0"
|
|
60
66
|
},
|
|
61
67
|
"dependencies": {
|
|
62
|
-
"triviumdb": "^0.8.
|
|
68
|
+
"triviumdb": "^0.8.11"
|
|
63
69
|
},
|
|
64
70
|
"keywords": [
|
|
65
71
|
"dsh-plugin",
|