dsh-context-mode 0.3.1 → 0.3.2

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 CHANGED
@@ -92,6 +92,38 @@ only accepts its own platform ids, and `pi` is its neutral MCP-only id — every
92
92
  store stays DSH-owned: `CONTEXT_MODE_DIR` isolates DSH data and
93
93
  `CONTEXT_MODE_PROJECT_DIR` pins project hashing to the configured workspace.
94
94
 
95
+ ## Compaction archiving
96
+
97
+ Compaction replaces the live conversation with a generated summary and prunes
98
+ the events behind it, so anything the summary omits leaves the model's reach.
99
+ The plugin listens for `compaction/start` and files the transcript into the
100
+ knowledge base first, where `ctx_search` can still reach it afterwards.
101
+
102
+ Storage is layered rather than filtered — every transcript event is archived,
103
+ and the layers differ only in the `source` label they carry, so precision is
104
+ chosen at query time instead of at write time:
105
+
106
+ | Source | Contents |
107
+ | --- | --- |
108
+ | `session/<id>/constraint` | user messages — requirements, decisions, limits |
109
+ | `session/<id>/finding` | tool results and assistant prose stating a concrete value |
110
+ | `session/<id>/narrative` | remaining assistant prose — reasoning, plans |
111
+
112
+ Nothing is dropped at write, so a misclassification costs a query's precision
113
+ rather than the content itself. Harness-injected blocks (`<active_memory>`,
114
+ `<current_runtime_context>`, `<system-reminder>`, `<resume_snapshot>`) are the
115
+ one exception: they ride on `user/message`, are per-turn runtime noise rather
116
+ than transcript, and are discarded before layering. Set `precompact: false` to
117
+ turn archiving off entirely.
118
+
119
+ Archives written before 0.3.2 may contain those injected blocks. A one-shot
120
+ cleanup script removes them and leaves everything else alone:
121
+
122
+ ```sh
123
+ node node_modules/dsh-context-mode/scripts/cleanup-injected.mjs --db <path> # report only
124
+ node node_modules/dsh-context-mode/scripts/cleanup-injected.mjs --db <path> --apply # delete
125
+ ```
126
+
95
127
  ## Development
96
128
 
97
129
  ```sh
@@ -14,13 +14,17 @@
14
14
  *
15
15
  * - writes segment CJK runs into single characters separated by spaces,
16
16
  * which makes `unicode61` emit one token per character;
17
- * - queries segment identically and are joined as a phrase, so an adjacent
18
- * query like "缓存方案" becomes the phrase `"缓 方 案"` and only
19
- * matches documents where those characters appear adjacently.
20
- *
21
- * Phrase semantics matter: without the phrase the query would degrade to an
22
- * AND of single characters, matching any document that merely contains all of
23
- * them. See {@link buildCjkQuery}.
17
+ * - queries segment identically, so "缓存方案" becomes the tokens
18
+ * `缓 案` and the searcher can match them.
19
+ *
20
+ * The query side deliberately does NOT wrap the segmented run in a phrase.
21
+ * Phrase semantics were tried first and made retrieval worse: a document
22
+ * saying "缓存走本地文件" and a query saying "缓存方案用什么" share the
23
+ * prefix but diverge immediately, so an adjacency requirement rejects the
24
+ * result a caller actually wanted. Emitting single tokens instead lets
25
+ * upstream's `sanitizeQuery` build an AND expression, and BM25 ranks the
26
+ * document that shares more characters first, which is the ranking a
27
+ * character-based index can honestly provide. See {@link buildCjkQuery}.
24
28
  */
25
29
  /** Return whether a string contains any character that needs segmentation. */
26
30
  export declare function hasCjk(value: string): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"cjk.d.ts","sourceRoot":"","sources":["../../src/cjk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAOH,8EAA8E;AAC9E,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE7C;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGhD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnD"}
1
+ {"version":3,"file":"cjk.d.ts","sourceRoot":"","sources":["../../src/cjk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAOH,8EAA8E;AAC9E,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE7C;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGhD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnD"}
package/lib/types/cjk.js CHANGED
@@ -14,13 +14,17 @@
14
14
  *
15
15
  * - writes segment CJK runs into single characters separated by spaces,
16
16
  * which makes `unicode61` emit one token per character;
17
- * - queries segment identically and are joined as a phrase, so an adjacent
18
- * query like "缓存方案" becomes the phrase `"缓 方 案"` and only
19
- * matches documents where those characters appear adjacently.
17
+ * - queries segment identically, so "缓存方案" becomes the tokens
18
+ * `缓 案` and the searcher can match them.
20
19
  *
21
- * Phrase semantics matter: without the phrase the query would degrade to an
22
- * AND of single characters, matching any document that merely contains all of
23
- * them. See {@link buildCjkQuery}.
20
+ * The query side deliberately does NOT wrap the segmented run in a phrase.
21
+ * Phrase semantics were tried first and made retrieval worse: a document
22
+ * saying "缓存走本地文件" and a query saying "缓存方案用什么" share the
23
+ * prefix but diverge immediately, so an adjacency requirement rejects the
24
+ * result a caller actually wanted. Emitting single tokens instead lets
25
+ * upstream's `sanitizeQuery` build an AND expression, and BM25 ranks the
26
+ * document that shares more characters first, which is the ranking a
27
+ * character-based index can honestly provide. See {@link buildCjkQuery}.
24
28
  */
25
29
  /** Han, Hiragana, Katakana, and Hangul ranges that need segmentation. */
26
30
  const CJK_PATTERN = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
@@ -75,6 +75,29 @@ export declare function installPrecompactArchive(ctx: Context, getClient: () =>
75
75
  * reached are searchable beside the evidence.
76
76
  */
77
77
  export declare function classify(events: readonly SessionEventLike[]): ArchivedLine[];
78
+ /**
79
+ * Whether a message body is harness-injected context rather than transcript.
80
+ *
81
+ * DSH attaches `<current_runtime_context>`, `<active_memory>`,
82
+ * `<system-reminder>`, and `<resume_snapshot>` blocks to user messages, so
83
+ * they arrive with the same `user/message` type as a genuine user turn. They
84
+ * are per-turn runtime noise, not requirements or decisions: filing them under
85
+ * `constraint` both dilutes that layer and returns stale policy snapshots for
86
+ * policy-shaped queries. `<active_memory>` is also a second-hand summary of
87
+ * events that are archived directly, so keeping it would store the same facts
88
+ * twice.
89
+ *
90
+ * The check has to cover two shapes. The tag form is what the model sees when
91
+ * a block is inlined whole, but `textOf` reads only the text blocks of a
92
+ * message, so a block's opening tag can be stripped before this point and the
93
+ * body then begins with the injected block's own heading — the transcripts
94
+ * this was written against start with "Current runtime context." rather than
95
+ * with a tag. Matching the headings as well keeps those from being filed.
96
+ *
97
+ * A body qualifies only when an injected marker *starts* the message, so a
98
+ * user who quotes one of these tags mid-sentence is still archived.
99
+ */
100
+ export declare function isInjectedContext(text: string): boolean;
78
101
  /**
79
102
  * Whether assistant prose states a value worth retrieving on its own.
80
103
  *
@@ -1 +1 @@
1
- {"version":3,"file":"precompact.d.ts","sourceRoot":"","sources":["../../src/precompact.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACxD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAGrD,+DAA+D;AAC/D,eAAO,MAAM,MAAM;;;;CAIT,CAAA;AAEV,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,MAAM,CAAC,CAAC,MAAM,OAAO,MAAM,CAAC,CAAA;AAE5D,qDAAqD;AACrD,MAAM,WAAW,iBAAiB;IAChC,iCAAiC;IACjC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,iFAAiF;IACjF,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CACnC;AAED,UAAU,gBAAgB;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CACxB;AAYD,6EAA6E;AAC7E,UAAU,YAAY;IACpB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAcD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,MAAM,cAAc,GAAG,SAAS,EAC3C,OAAO,GAAE,iBAAsB,GAC9B,MAAM,IAAI,CAiCZ;AA+CD;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,SAAS,gBAAgB,EAAE,GAAG,YAAY,EAAE,CAU5E;AAYD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAO1D;AAcD,mFAAmF;AACnF,wBAAgB,MAAM,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAwBtD;AAWD,wEAAwE;AACxE,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3C;AAaD,6EAA6E;AAC7E,MAAM,MAAM,aAAa,GAAG,YAAY,CAAA"}
1
+ {"version":3,"file":"precompact.d.ts","sourceRoot":"","sources":["../../src/precompact.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACxD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAGrD,+DAA+D;AAC/D,eAAO,MAAM,MAAM;;;;CAIT,CAAA;AAEV,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,MAAM,CAAC,CAAC,MAAM,OAAO,MAAM,CAAC,CAAA;AAE5D,qDAAqD;AACrD,MAAM,WAAW,iBAAiB;IAChC,iCAAiC;IACjC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,iFAAiF;IACjF,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CACnC;AAED,UAAU,gBAAgB;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CACxB;AAYD,6EAA6E;AAC7E,UAAU,YAAY;IACpB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAcD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,MAAM,cAAc,GAAG,SAAS,EAC3C,OAAO,GAAE,iBAAsB,GAC9B,MAAM,IAAI,CAiCZ;AA+CD;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,SAAS,gBAAgB,EAAE,GAAG,YAAY,EAAE,CAW5E;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAoBD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAO1D;AAcD,mFAAmF;AACnF,wBAAgB,MAAM,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAwBtD;AAWD,wEAAwE;AACxE,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3C;AAaD,6EAA6E;AAC7E,MAAM,MAAM,aAAa,GAAG,YAAY,CAAA"}
@@ -136,6 +136,8 @@ export function classify(events) {
136
136
  const text = textOf(event);
137
137
  if (text.length === 0)
138
138
  continue;
139
+ if (isInjectedContext(text))
140
+ continue;
139
141
  const layer = layerOf(event.type, text);
140
142
  if (layer === undefined)
141
143
  continue;
@@ -143,6 +145,36 @@ export function classify(events) {
143
145
  }
144
146
  return lines;
145
147
  }
148
+ /**
149
+ * Whether a message body is harness-injected context rather than transcript.
150
+ *
151
+ * DSH attaches `<current_runtime_context>`, `<active_memory>`,
152
+ * `<system-reminder>`, and `<resume_snapshot>` blocks to user messages, so
153
+ * they arrive with the same `user/message` type as a genuine user turn. They
154
+ * are per-turn runtime noise, not requirements or decisions: filing them under
155
+ * `constraint` both dilutes that layer and returns stale policy snapshots for
156
+ * policy-shaped queries. `<active_memory>` is also a second-hand summary of
157
+ * events that are archived directly, so keeping it would store the same facts
158
+ * twice.
159
+ *
160
+ * The check has to cover two shapes. The tag form is what the model sees when
161
+ * a block is inlined whole, but `textOf` reads only the text blocks of a
162
+ * message, so a block's opening tag can be stripped before this point and the
163
+ * body then begins with the injected block's own heading — the transcripts
164
+ * this was written against start with "Current runtime context." rather than
165
+ * with a tag. Matching the headings as well keeps those from being filed.
166
+ *
167
+ * A body qualifies only when an injected marker *starts* the message, so a
168
+ * user who quotes one of these tags mid-sentence is still archived.
169
+ */
170
+ export function isInjectedContext(text) {
171
+ return INJECTED_CONTEXT_PATTERN.test(text);
172
+ }
173
+ const INJECTED_CONTEXT_PATTERN = new RegExp('^\\s*(?:' +
174
+ '<(?:current_runtime_context|active_memory|system-reminder|resume_snapshot)\\b' +
175
+ '|Current runtime context\\b' +
176
+ '|The available skill catalog changed\\b' +
177
+ ')');
146
178
  /** Return the layer for one event, or undefined when it carries no transcript value. */
147
179
  function layerOf(type, text) {
148
180
  if (type === 'user/message')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-context-mode",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Expose context-mode MCP tools as native DeepSeek Harness tools",
5
5
  "keywords": [
6
6
  "dsh",
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "files": [
31
31
  "lib",
32
+ "scripts/cleanup-injected.mjs",
32
33
  "skills",
33
34
  "vendor/context-mode/server.bundle.mjs",
34
35
  "vendor/context-mode/LICENSE",
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Remove harness-injected context blocks that earlier builds filed as
4
+ * constraints.
5
+ *
6
+ * Before `isInjectedContext` existed, `precompact` classified purely on event
7
+ * type, so the `<current_runtime_context>` / `<active_memory>` blocks DSH
8
+ * attaches to user messages were archived as `session/<id>/constraint` — the
9
+ * layer meant to hold requirements and decisions. Those rows are runtime
10
+ * noise, and they are stale policy snapshots besides.
11
+ *
12
+ * Scope is deliberately narrow. A row is only a candidate when it lives in an
13
+ * archived session layer AND carries an injected marker immediately after the
14
+ * archive's own `## [layer] party (seq N)` heading. A document or source file
15
+ * that merely *mentions* `active_memory` (the plugin's own sources do) keeps
16
+ * its row, because such a match is never preceded by an archive heading.
17
+ *
18
+ * Usage:
19
+ * node scripts/cleanup-injected.mjs --db <path> [--apply]
20
+ *
21
+ * Without `--apply` nothing is written; the script only reports what it would
22
+ * delete. Both FTS5 tables are cleaned together, since `chunks` and
23
+ * `chunks_trigram` hold the same logical rows and would otherwise disagree.
24
+ */
25
+ import { DatabaseSync } from 'node:sqlite'
26
+ import { existsSync } from 'node:fs'
27
+
28
+ const args = process.argv.slice(2)
29
+ const apply = args.includes('--apply')
30
+ const dbIndex = args.indexOf('--db')
31
+ const dbPath = dbIndex >= 0 ? args[dbIndex + 1] : undefined
32
+
33
+ if (dbPath === undefined) {
34
+ console.error('usage: node scripts/cleanup-injected.mjs --db <path> [--apply]')
35
+ process.exit(2)
36
+ }
37
+ if (!existsSync(dbPath)) {
38
+ console.error(`database not found: ${dbPath}`)
39
+ process.exit(2)
40
+ }
41
+
42
+ /** Markers that identify a harness-injected block, matched after the heading. */
43
+ const MARKERS = [
44
+ '<current_runtime_context',
45
+ '<active_memory',
46
+ '<system-reminder',
47
+ '<resume_snapshot',
48
+ 'Current runtime context',
49
+ 'The available skill catalog changed',
50
+ ]
51
+
52
+ /** Archive headings look like `## [约束] 用户 (seq 2766)`. */
53
+ const heading = '## [%'
54
+ const patterns = MARKERS.map(marker => `${heading}%${marker}%`)
55
+
56
+ /** Build the content predicate for one table alias. */
57
+ const clausesFor = alias => MARKERS.map(() => `(${alias}.content LIKE ?)`).join(' OR ')
58
+
59
+ const selectSql = `
60
+ SELECT c.rowid AS rowid, s.label AS label, c.content AS content
61
+ FROM chunks c
62
+ JOIN sources s ON s.id = c.source_id
63
+ WHERE s.label LIKE 'session/%'
64
+ AND (${clausesFor('c')})
65
+ `
66
+
67
+ const db = new DatabaseSync(dbPath)
68
+ const rows = db.prepare(selectSql).all(...patterns)
69
+
70
+ // `chunks_trigram` mirrors `chunks`; the same logical row has a different
71
+ // rowid per table, so the trigram side is matched by content within the same
72
+ // session-scoped sources.
73
+ const trigramSql = `
74
+ SELECT t.rowid AS rowid, s.label AS label, t.content AS content
75
+ FROM chunks_trigram t
76
+ JOIN sources s ON s.id = t.source_id
77
+ WHERE s.label LIKE 'session/%'
78
+ AND (${clausesFor('t')})
79
+ `
80
+ const trigramRows = db.prepare(trigramSql).all(...patterns)
81
+
82
+ console.log(`mode: ${apply ? 'APPLY' : 'DRY RUN'}`)
83
+ console.log(`database: ${dbPath}`)
84
+ console.log(`chunks rows matched: ${rows.length}`)
85
+ console.log(`chunks_trigram rows matched: ${trigramRows.length}`)
86
+
87
+ for (const row of rows) {
88
+ const preview = row.content.replace(/\s+/g, ' ').slice(0, 96)
89
+ console.log(` [${row.rowid}] ${row.label}\n ${preview}`)
90
+ }
91
+
92
+ if (rows.length === 0 && trigramRows.length === 0) {
93
+ console.log('\nnothing to clean')
94
+ db.close()
95
+ process.exit(0)
96
+ }
97
+
98
+ if (!apply) {
99
+ console.log('\ndry run only — re-run with --apply to delete these rows')
100
+ db.close()
101
+ process.exit(0)
102
+ }
103
+
104
+ db.exec('BEGIN')
105
+ try {
106
+ const delChunks = db.prepare('DELETE FROM chunks WHERE rowid = ?')
107
+ for (const row of rows) delChunks.run(row.rowid)
108
+ const delTrigram = db.prepare('DELETE FROM chunks_trigram WHERE rowid = ?')
109
+ for (const row of trigramRows) delTrigram.run(row.rowid)
110
+ db.exec('COMMIT')
111
+ } catch (error) {
112
+ db.exec('ROLLBACK')
113
+ console.error('cleanup failed, rolled back:', error)
114
+ db.close()
115
+ process.exit(1)
116
+ }
117
+
118
+ // `sources.chunk_count` is a cached tally; recompute it for the labels touched
119
+ // so the bookkeeping matches the rows that remain.
120
+ const touched = new Set([...rows, ...trigramRows].map(row => row.label))
121
+ const recount = db.prepare(`
122
+ UPDATE sources
123
+ SET chunk_count = (
124
+ SELECT COUNT(*) FROM chunks c WHERE c.source_id = sources.id
125
+ )
126
+ WHERE label = ?
127
+ `)
128
+ for (const label of touched) recount.run(label)
129
+
130
+ // Reclaim the space freed by the deletes.
131
+ db.exec("INSERT INTO chunks(chunks) VALUES('optimize')")
132
+ db.exec("INSERT INTO chunks_trigram(chunks_trigram) VALUES('optimize')")
133
+
134
+ console.log(`\ndeleted ${rows.length} + ${trigramRows.length} rows, recounted ${touched.size} source(s)`)
135
+ db.close()