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.
@@ -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
- name: "ctx_find",
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
- const grouped = [];
80
- let lastType = "";
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
- if (row.type !== lastType) {
84
- grouped.push(`## ${row.type}`);
85
- lastType = row.type;
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
- grouped.push(
88
- `[${row.score.toFixed(3)}] id=${row.id} type=${row.type}${row.until ? ` until=${row.until}` : ""}${row.stale ? " stale" : ""}\n${row.l0}\npath: ${row.path.join(" | ") || "(none)"}`,
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 grouped.join("\n\n");
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
- name: "ctx_read",
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
- name: "ctx_remember",
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: { sessionId: String(exec?.agent?.session?.header?.id || exec?.agent?.id || "") },
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
- name: "ctx_link",
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
- /** Pull a display `until` label out of a fact sentence. */
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.4.21",
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.9"
68
+ "triviumdb": "^0.8.11"
63
69
  },
64
70
  "keywords": [
65
71
  "dsh-plugin",