@polycode-projects/the-mechanical-code-talker 0.3.0 → 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 +77 -3
- package/ROADMAP.md +412 -2
- package/bin/tmct.mjs +56 -1
- package/data/templates/grammar-rules.toml +97 -0
- package/data/templates/responses.jsonl +13 -0
- package/package.json +30 -2
- package/src/ask-nlp.mjs +8 -10
- package/src/ask-vocab.mjs +22 -0
- package/src/ask.mjs +170 -8
- package/src/chat.mjs +740 -53
- package/src/corpus/conceptnet.mjs +14 -2
- package/src/corpus/templates.mjs +94 -10
- package/src/finish.mjs +443 -0
- package/src/grammar/lexicon-core.json +8 -0
- package/src/hash.mjs +32 -0
- package/src/init.mjs +264 -0
- package/src/interpret/normalize.mjs +34 -0
- package/src/interpret/strategies/keywords.mjs +57 -1
- package/src/memory/blocks.mjs +23 -3
- package/src/memory/core.mjs +257 -16
- package/src/memory/fold.mjs +0 -0
- package/src/memory/inspect.mjs +141 -0
- package/src/memory/trust.mjs +113 -0
- package/src/prose-nlp.mjs +14 -16
- package/src/providers/bootstrap.mjs +24 -0
- package/src/providers/fixture.mjs +118 -0
- package/src/providers/graph-service.mjs +312 -0
- package/src/repository-interface.mjs +318 -0
- package/src/server.mjs +44 -28
- package/src/sessions.mjs +13 -2
- package/src/syllogise.mjs +0 -0
- package/src/toml-config.mjs +14 -0
- package/src/wink-model.mjs +74 -0
package/src/init.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// init.mjs — `tmct init`: the interface's onboarding surface.
|
|
2
|
+
//
|
|
3
|
+
// One command takes a bare directory (a user's repo, or a host package such as
|
|
4
|
+
// seonix) to a WORKING tmct install: it creates the `.tmct/` artifact tree,
|
|
5
|
+
// writes an externalised `tmct.toml` (the seonix.toml documented-config pattern),
|
|
6
|
+
// seeds the committed tier-1 corpus into memory, and records provenance of what
|
|
7
|
+
// it did. See ROADMAP Phase 8 ("Distribution: tmct init") and the Phase-4
|
|
8
|
+
// corpus-tiering policy.
|
|
9
|
+
//
|
|
10
|
+
// initRepo(dir, { force?, seed?, env? }) → { created, config, seeded, ... }
|
|
11
|
+
//
|
|
12
|
+
// DESIGN RULES (load-bearing):
|
|
13
|
+
// - OFFLINE, DETERMINISTIC, $0. The seed is the tier-1 committed ConceptNet
|
|
14
|
+
// slice already in the tarball — no network, ever. The $0-offline default is
|
|
15
|
+
// inviolable (ROADMAP Phase 4); tiers 2-3 are additive config, never run here.
|
|
16
|
+
// - IDEMPOTENT and NON-DESTRUCTIVE. Safe to re-run. A benign re-init NEVER
|
|
17
|
+
// throws — it returns an honest result whose `message` says nothing changed.
|
|
18
|
+
// Existing `tmct.toml` and an existing seed are preserved unless `force`.
|
|
19
|
+
// - FAILURE-TOLERANT SEED. A missing/broken corpus degrades to an unseeded (but
|
|
20
|
+
// still initialised) repo — the directory scaffold and config always land.
|
|
21
|
+
//
|
|
22
|
+
// The seed marker + limit + prefer mirror src/chat.mjs's W3 bootstrap
|
|
23
|
+
// (SEED_MARKER_REL / SEED_LIMIT / SEED_PREFER) ON PURPOSE: both write the same
|
|
24
|
+
// `.tmct/memory/corpus-seed.json`, so whichever of `tmct init` and first-run
|
|
25
|
+
// bootstrap happens first wins and the other short-circuits. They are re-declared
|
|
26
|
+
// here (not imported) to keep init off chat.mjs's heavy module graph.
|
|
27
|
+
|
|
28
|
+
import { mkdir, readFile, writeFile, stat } from "node:fs/promises";
|
|
29
|
+
import { dirname, join, resolve } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
|
|
32
|
+
export const CONFIG_FILE = "tmct.toml";
|
|
33
|
+
export const PROVENANCE_REL = join(".tmct", "init.json");
|
|
34
|
+
export const MEMORY_DIR_REL = join(".tmct", "memory");
|
|
35
|
+
export const SESSIONS_DIR_REL = join(".tmct", "sessions");
|
|
36
|
+
export const SEED_MARKER_REL = join(".tmct", "memory", "corpus-seed.json");
|
|
37
|
+
|
|
38
|
+
/** How many corpus facts the seed writes — matches chat.mjs SEED_LIMIT so an
|
|
39
|
+
* init-seeded repo and a bootstrap-seeded repo carry the identical slice. */
|
|
40
|
+
export const SEED_LIMIT = 500;
|
|
41
|
+
|
|
42
|
+
/** Predicate preference for the capped seed (definitional band first) — matches
|
|
43
|
+
* chat.mjs SEED_PREFER so "what is a cache?" answers land in the first 500. */
|
|
44
|
+
export const SEED_PREFER = ["rdfs:subClassOf", "rdf:type", "mgx:usedFor", "mgx:partOf", "mgx:capableOf"];
|
|
45
|
+
|
|
46
|
+
/** The shipped default config — the exact shape written into `tmct.toml` and
|
|
47
|
+
* echoed back in the result's `config`. Absent file ⇒ these values apply. */
|
|
48
|
+
export function defaultConfig() {
|
|
49
|
+
return {
|
|
50
|
+
graphFile: join(".tmct", "graph.json"),
|
|
51
|
+
corpus: { tier: "tier1" },
|
|
52
|
+
seed: { enabled: true, limit: SEED_LIMIT },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read this package's version (best-effort, for provenance). */
|
|
57
|
+
async function tmctVersion() {
|
|
58
|
+
try {
|
|
59
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
60
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
61
|
+
return pkg.version || null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function exists(p) {
|
|
68
|
+
try {
|
|
69
|
+
await stat(p);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Render the documented, commented `tmct.toml` for a config object. Hand-written
|
|
77
|
+
* (not smol-toml stringify) so every key ships with the prose that makes the file
|
|
78
|
+
* a self-explaining config surface — the seonix.toml pattern. The output parses
|
|
79
|
+
* back cleanly through toml-config.mjs's loadTomlConfig. */
|
|
80
|
+
export function renderTomlConfig(config = defaultConfig()) {
|
|
81
|
+
const c = { ...defaultConfig(), ...config };
|
|
82
|
+
const corpus = { ...defaultConfig().corpus, ...(config.corpus || {}) };
|
|
83
|
+
const seed = { ...defaultConfig().seed, ...(config.seed || {}) };
|
|
84
|
+
return `# tmct.toml — the mechanical code talker, project configuration.
|
|
85
|
+
# Written by \`tmct init\`. An ABSENT file means shipped defaults (this file
|
|
86
|
+
# just makes them explicit and editable). Documented in the repository-interface
|
|
87
|
+
# onboarding surface (ROADMAP Phase 8, "Distribution: tmct init").
|
|
88
|
+
|
|
89
|
+
# Where the code-graph JSON artifact lives, relative to this file. The
|
|
90
|
+
# TMCT_GRAPH_FILE environment variable overrides it at runtime.
|
|
91
|
+
graph_file = ${JSON.stringify(c.graphFile)}
|
|
92
|
+
|
|
93
|
+
[corpus]
|
|
94
|
+
# Corpus-tiering policy (ROADMAP Phase 4). The $0-offline default is inviolable;
|
|
95
|
+
# higher tiers are ADDITIVE and never required to answer.
|
|
96
|
+
# "tier1" — committed slice only. Offline, $0. The default.
|
|
97
|
+
# "tier2" — also fetch growable corpora at seed time (network, once, cached).
|
|
98
|
+
# "tier3" — also consult live sources at question time (network, per-query, opt-in).
|
|
99
|
+
tier = ${JSON.stringify(corpus.tier)}
|
|
100
|
+
|
|
101
|
+
[seed]
|
|
102
|
+
# Seed the committed tier-1 ConceptNet slice into .tmct/memory during init.
|
|
103
|
+
# Offline and deterministic. Set false, or export TMCT_NO_SEED=1, to opt out —
|
|
104
|
+
# the repo still initialises, just empty of corpus facts.
|
|
105
|
+
enabled = ${seed.enabled ? "true" : "false"}
|
|
106
|
+
# How many facts the seed writes (definitional band first).
|
|
107
|
+
limit = ${Number(seed.limit)}
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Should the seed run? Explicit `opts.seed` wins; otherwise the config's
|
|
112
|
+
* `seed.enabled`; and TMCT_NO_SEED=<non-empty> is a hard veto over both (the
|
|
113
|
+
* documented environment opt-out, honoured even when config says enabled). */
|
|
114
|
+
function seedRequested({ optSeed, configEnabled, env }) {
|
|
115
|
+
const noSeed = env && String(env.TMCT_NO_SEED || "").trim();
|
|
116
|
+
if (noSeed) return false;
|
|
117
|
+
if (optSeed !== undefined) return Boolean(optSeed);
|
|
118
|
+
return Boolean(configEnabled);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Initialise `dir` for tmct. Idempotent, non-destructive, offline.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} dir target directory (a repo root, or a host package root).
|
|
125
|
+
* @param {object} [opts]
|
|
126
|
+
* @param {boolean} [opts.force] re-write tmct.toml + re-record provenance even
|
|
127
|
+
* when the repo is already initialised (never deletes memory/seed data).
|
|
128
|
+
* @param {boolean} [opts.seed] force seeding on/off, overriding tmct.toml's
|
|
129
|
+
* `seed.enabled` (TMCT_NO_SEED still vetoes).
|
|
130
|
+
* @param {object} [opts.env] environment (for TMCT_NO_SEED); defaults to
|
|
131
|
+
* process.env.
|
|
132
|
+
* @returns {Promise<{
|
|
133
|
+
* created: string[], config: object, seeded: boolean,
|
|
134
|
+
* alreadyInitialized: boolean, seedResult: (object|null), message: string
|
|
135
|
+
* }>} `created` lists the ABSOLUTE paths this call brought into being (empty on a
|
|
136
|
+
* benign no-op re-init). Never throws on a benign re-init or a corpus failure.
|
|
137
|
+
*/
|
|
138
|
+
export async function initRepo(dir, { force = false, seed, env = process.env } = {}) {
|
|
139
|
+
const root = resolve(dir);
|
|
140
|
+
const created = [];
|
|
141
|
+
const paths = {
|
|
142
|
+
tmct: join(root, ".tmct"),
|
|
143
|
+
memory: join(root, MEMORY_DIR_REL),
|
|
144
|
+
sessions: join(root, SESSIONS_DIR_REL),
|
|
145
|
+
toml: join(root, CONFIG_FILE),
|
|
146
|
+
provenance: join(root, PROVENANCE_REL),
|
|
147
|
+
marker: join(root, SEED_MARKER_REL),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const wasInitialized = await exists(paths.provenance);
|
|
151
|
+
|
|
152
|
+
// ---- 1. The artifact directory scaffold (always idempotent) ----
|
|
153
|
+
for (const d of [paths.tmct, paths.memory, paths.sessions]) {
|
|
154
|
+
if (!(await exists(d))) {
|
|
155
|
+
await mkdir(d, { recursive: true });
|
|
156
|
+
created.push(d);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- 2. The externalised config (preserve an existing file unless force) ----
|
|
161
|
+
let config = defaultConfig();
|
|
162
|
+
const tomlPresent = await exists(paths.toml);
|
|
163
|
+
if (!tomlPresent || force) {
|
|
164
|
+
await writeFile(paths.toml, renderTomlConfig(config));
|
|
165
|
+
if (!tomlPresent) created.push(paths.toml);
|
|
166
|
+
} else {
|
|
167
|
+
// Honour the user's committed tmct.toml — read its knobs back so the returned
|
|
168
|
+
// config (and the seed decision) reflect what's actually on disk.
|
|
169
|
+
config = await readWrittenConfig(paths.toml, config);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---- 3. Seed the tier-1 committed corpus (offline, failure-tolerant) ----
|
|
173
|
+
let seeded = false;
|
|
174
|
+
let seedResult = null;
|
|
175
|
+
let seedNote = "";
|
|
176
|
+
const wantSeed = seedRequested({ optSeed: seed, configEnabled: config.seed?.enabled, env });
|
|
177
|
+
if (!wantSeed) {
|
|
178
|
+
seedNote = env && String(env.TMCT_NO_SEED || "").trim()
|
|
179
|
+
? "seed skipped (TMCT_NO_SEED set)"
|
|
180
|
+
: "seed skipped (disabled)";
|
|
181
|
+
} else if ((await exists(paths.marker)) && !force) {
|
|
182
|
+
seedNote = "seed skipped (already seeded — marker present)";
|
|
183
|
+
} else {
|
|
184
|
+
try {
|
|
185
|
+
const { seedMemory } = await import("./corpus/conceptnet.mjs");
|
|
186
|
+
const limit = Number(config.seed?.limit) || SEED_LIMIT;
|
|
187
|
+
seedResult = await seedMemory(root, { limit, prefer: SEED_PREFER });
|
|
188
|
+
const markerNew = !(await exists(paths.marker));
|
|
189
|
+
await mkdir(dirname(paths.marker), { recursive: true });
|
|
190
|
+
await writeFile(
|
|
191
|
+
paths.marker,
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
seededAt: new Date().toISOString(),
|
|
194
|
+
limit,
|
|
195
|
+
appended: seedResult.appended,
|
|
196
|
+
skipped: seedResult.skipped,
|
|
197
|
+
}) + "\n",
|
|
198
|
+
);
|
|
199
|
+
if (markerNew) created.push(paths.marker);
|
|
200
|
+
seeded = true;
|
|
201
|
+
} catch (err) {
|
|
202
|
+
// Corpus unavailable/broken → an initialised-but-unseeded repo, not a crash.
|
|
203
|
+
seedNote = `seed skipped (corpus unavailable: ${err && err.message ? err.message : err})`;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- 4. Record provenance (what was created, when, by which version) ----
|
|
208
|
+
const provenanceNew = !(await exists(paths.provenance));
|
|
209
|
+
const provenance = {
|
|
210
|
+
tool: "tmct init",
|
|
211
|
+
tmctVersion: await tmctVersion(),
|
|
212
|
+
initializedAt: new Date().toISOString(),
|
|
213
|
+
dir: root,
|
|
214
|
+
config,
|
|
215
|
+
seeded,
|
|
216
|
+
seedResult,
|
|
217
|
+
created,
|
|
218
|
+
};
|
|
219
|
+
await writeFile(paths.provenance, JSON.stringify(provenance, null, 2) + "\n");
|
|
220
|
+
if (provenanceNew) created.push(paths.provenance);
|
|
221
|
+
|
|
222
|
+
const alreadyInitialized = wasInitialized && !force;
|
|
223
|
+
const message = buildMessage({ alreadyInitialized, force, created, seeded, seedNote, seedResult });
|
|
224
|
+
|
|
225
|
+
return { created, config, seeded, alreadyInitialized, seedResult, message };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Read a present tmct.toml back into the canonical config shape (so a re-init
|
|
229
|
+
* respects the on-disk file). Falls back to `base` on any read/parse trouble —
|
|
230
|
+
* init must never crash on a malformed user file; the runtime loader
|
|
231
|
+
* (toml-config.mjs) is where a bad file surfaces its error. */
|
|
232
|
+
async function readWrittenConfig(tomlPath, base) {
|
|
233
|
+
try {
|
|
234
|
+
const { loadTomlConfig } = await import("./toml-config.mjs");
|
|
235
|
+
const raw = await loadTomlConfig(dirname(tomlPath));
|
|
236
|
+
if (!raw) return base;
|
|
237
|
+
const cfg = { ...base };
|
|
238
|
+
if (raw.graph_file !== undefined) cfg.graphFile = String(raw.graph_file);
|
|
239
|
+
if (raw.corpus && raw.corpus.tier !== undefined) cfg.corpus = { ...cfg.corpus, tier: raw.corpus.tier };
|
|
240
|
+
if (raw.seed) {
|
|
241
|
+
cfg.seed = { ...cfg.seed };
|
|
242
|
+
if (raw.seed.enabled !== undefined) cfg.seed.enabled = Boolean(raw.seed.enabled);
|
|
243
|
+
if (raw.seed.limit !== undefined) cfg.seed.limit = Number(raw.seed.limit);
|
|
244
|
+
}
|
|
245
|
+
return cfg;
|
|
246
|
+
} catch {
|
|
247
|
+
return base;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function buildMessage({ alreadyInitialized, force, created, seeded, seedNote, seedResult }) {
|
|
252
|
+
if (alreadyInitialized && created.length === 0) {
|
|
253
|
+
return `Already initialized — nothing to do (re-run with force to rewrite tmct.toml). ${seedNote || ""}`.trim();
|
|
254
|
+
}
|
|
255
|
+
const parts = [];
|
|
256
|
+
parts.push(force && alreadyInitialized ? "Re-initialized" : "Initialized");
|
|
257
|
+
parts.push(`tmct here (${created.length} path${created.length === 1 ? "" : "s"} created).`);
|
|
258
|
+
if (seeded && seedResult) {
|
|
259
|
+
parts.push(`Seeded ${seedResult.appended} corpus fact${seedResult.appended === 1 ? "" : "s"} into memory.`);
|
|
260
|
+
} else if (seedNote) {
|
|
261
|
+
parts.push(seedNote + ".");
|
|
262
|
+
}
|
|
263
|
+
return parts.join(" ");
|
|
264
|
+
}
|
|
@@ -90,6 +90,40 @@ export function applyNegationFrames(text) {
|
|
|
90
90
|
return text;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// ---- §B1 negation — the SET-COMPLEMENT frame (Cycle 5, PLAN_CYCLE_4.md). Recognizes
|
|
94
|
+
// a BARE set-negation query — "which X do not <verb> Y", "X that don't <verb> Y",
|
|
95
|
+
// "modules not importing Y", "which X are not <qualifier>" — and returns a descriptor
|
|
96
|
+
// {entWord, predicate} that ask.mjs's compositional grammar turns into a bounded
|
|
97
|
+
// complement (allOfClass(kind) MINUS the positive result set). Deliberately SEPARATE
|
|
98
|
+
// from applyNegationFrames/NEGATION_FRAMES above: that table is a rhetorical
|
|
99
|
+
// double-negative rewriter that REMOVES negation ("there isn't anything calling it" ->
|
|
100
|
+
// "what calls it") and its docblock forbids scope parsing; this detector PRESERVES the
|
|
101
|
+
// negation as a set operation. Returns null when no set-negation marker is present, so
|
|
102
|
+
// every affirmative query passes through untouched (the active-voice regression guard).
|
|
103
|
+
// The entWord is validated against the entity vocabulary by the caller, which also
|
|
104
|
+
// enforces the bounded-universe refusal for the non-enumerable "changes" pseudo-type. ----
|
|
105
|
+
const NEGATION_SET_RE = new RegExp(
|
|
106
|
+
"^(?:which|what|who|list|show(?:\\s+me)?|find|give\\s+me)?\\s*(?:the\\s+|all\\s+)?" // optional frame + determiner
|
|
107
|
+
+ "([a-z][a-z-]*)\\s+" // (1) the entity kind noun
|
|
108
|
+
+ "(?:(?:that|which|who)\\s+)?" // optional relative pronoun
|
|
109
|
+
+ "(?:(?:do|does|did|are|is|was|were|have|has)\\s+)?" // optional auxiliary
|
|
110
|
+
+ "not\\s+(.+)$", // the negation marker + (2) the predicate
|
|
111
|
+
"i",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/** Recognize a bare set-negation query and return {entWord, predicate}, or null when
|
|
115
|
+
* no set-negation marker ("not") follows an entity noun. Pure text analysis — the
|
|
116
|
+
* caller (ask.mjs's parseNegation) validates the entity kind, refuses the
|
|
117
|
+
* non-enumerable "changes" universe, and builds the complement AST. */
|
|
118
|
+
export function matchNegationSet(text) {
|
|
119
|
+
const m = String(text || "").match(NEGATION_SET_RE);
|
|
120
|
+
if (!m) return null;
|
|
121
|
+
const entWord = m[1].toLowerCase();
|
|
122
|
+
const predicate = m[2].trim();
|
|
123
|
+
if (!predicate) return null;
|
|
124
|
+
return { entWord, predicate };
|
|
125
|
+
}
|
|
126
|
+
|
|
93
127
|
// ---- shared text-prep helpers (used by the strategies, the compositional
|
|
94
128
|
// grammar, and ask.mjs's relaxation cascade alike) ----
|
|
95
129
|
|
|
@@ -7,11 +7,20 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
|
|
10
|
-
WHERE_MARKERS, MENTION_MARKERS,
|
|
10
|
+
WHERE_MARKERS, MENTION_MARKERS, PLACEHOLDER_NOUNS, PASSIVE_PARTICIPLE_TO_KIND,
|
|
11
11
|
} from "../../ask-vocab.mjs";
|
|
12
12
|
import { STOPWORDS } from "../normalize.mjs";
|
|
13
13
|
import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
|
|
14
14
|
|
|
15
|
+
// Reversible-passive detection (Cycle 6, PLAN_CYCLE_4.md): the passive auxiliaries that,
|
|
16
|
+
// together with an agent-marking "by", flip the active reading, and the wh-words that
|
|
17
|
+
// mark a QUESTIONED agent ("by which classes" / stranded "who is X tested by"). Bare
|
|
18
|
+
// "do/does/did" are deliberately EXCLUDED — "which X do not <verb> Y" is a NEGATION, not
|
|
19
|
+
// a passive, and must be left for the compositional complement frame.
|
|
20
|
+
const PASSIVE_AUX = new Set(["is", "are", "was", "were", "be", "been", "being", "get", "gets", "got"]);
|
|
21
|
+
const WH_WORDS = new Set(["which", "what", "who", "whom", "whose"]);
|
|
22
|
+
const PLACEHOLDER_SET = new Set(PLACEHOLDER_NOUNS.map((w) => w.toLowerCase()));
|
|
23
|
+
|
|
15
24
|
/** Find the longest phrase from `table`'s keys that appears as a contiguous
|
|
16
25
|
* run of `words` (case already lowercased by the caller). Longest-match-first
|
|
17
26
|
* (multi-word phrases before single words) so "co-changes with" isn't
|
|
@@ -107,6 +116,17 @@ export function parseKeywordSpot(text, nlp = null) {
|
|
|
107
116
|
verbHit = findPhrase(fuzzyWords, VERB_TO_KIND);
|
|
108
117
|
if (verbHit) canonWords = fuzzyWords;
|
|
109
118
|
}
|
|
119
|
+
if (!verbHit && lcWords.includes("by")) {
|
|
120
|
+
// passive-participle rescue (Cycle 6): a participle whose kind is NOT a standalone
|
|
121
|
+
// active verb here ("defined" belongs to "is defined in"; bare "inherited" has no
|
|
122
|
+
// active key) still marks a passive when a passive auxiliary and an agent "by" are
|
|
123
|
+
// present. Consulted ONLY on this exact gate (aux + by), so the active grammar and
|
|
124
|
+
// the where-marker routing ("where is X defined") are never disturbed.
|
|
125
|
+
for (let i = 0; i < lcWords.length; i += 1) {
|
|
126
|
+
const k = PASSIVE_PARTICIPLE_TO_KIND[lcWords[i]];
|
|
127
|
+
if (k && lcWords.slice(0, i).some((w) => PASSIVE_AUX.has(w))) { verbHit = { kind: k, start: i, end: i + 1 }; break; }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
110
130
|
if (!verbHit) return null;
|
|
111
131
|
// POS consumer (wink adapter, Node-side only): rescue the ONE decomposition this
|
|
112
132
|
// strategy provably mis-parses — a relation word used as a NOUN in a "the
|
|
@@ -156,6 +176,42 @@ export function parseKeywordSpot(text, nlp = null) {
|
|
|
156
176
|
if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
|
|
157
177
|
}
|
|
158
178
|
|
|
179
|
+
// reversible passive (Cycle 6, PLAN_CYCLE_4.md): "PATIENT is VERBed BY AGENT" — an
|
|
180
|
+
// agent-marking "by" plus a passive auxiliary flips the active reading, so the AGENT
|
|
181
|
+
// (after "by") is the edge SUBJECT and the PATIENT the edge OBJECT. Object-first
|
|
182
|
+
// phrasing is otherwise read subject-first and the edge traversed backwards. Fires
|
|
183
|
+
// ONLY on a genuine agent "by": a passive auxiliary before the verb AND a standalone
|
|
184
|
+
// "by" NOT already swallowed into a multi-word verb phrase ("touched by"/"modified
|
|
185
|
+
// by" are single touches verbs, so their "by" is consumed and never triggers this) —
|
|
186
|
+
// an active query whose object merely contains a "by" token is untouched (the
|
|
187
|
+
// regression guard). The single NAMED role term becomes the object; whether the AGENT
|
|
188
|
+
// is named ("by b.test.mjs" → forward from the agent) or QUESTIONED ("by which
|
|
189
|
+
// classes" / a stranded "…tested by" → reverse over the patient) picks the direction.
|
|
190
|
+
const byIdx = lcWords.indexOf("by");
|
|
191
|
+
const hasPassiveAux = lcWords.slice(0, verbHit.start).some((w) => PASSIVE_AUX.has(w));
|
|
192
|
+
if (byIdx >= 0 && !consumed.has(byIdx) && hasPassiveAux) {
|
|
193
|
+
const roleWords = [];
|
|
194
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
195
|
+
const w = lcWords[i];
|
|
196
|
+
if (consumed.has(i) || STOPWORDS.has(w) || w === "by" || PASSIVE_AUX.has(w)
|
|
197
|
+
|| WH_WORDS.has(w) || PLACEHOLDER_SET.has(w)) continue;
|
|
198
|
+
roleWords.push(words[i]);
|
|
199
|
+
}
|
|
200
|
+
const object = roleWords.join(" ").trim();
|
|
201
|
+
if (object) {
|
|
202
|
+
// the first meaningful token after "by" (skipping only articles) decides direction:
|
|
203
|
+
// a wh-word or nothing → the agent is questioned (reverse over the named patient);
|
|
204
|
+
// a named token → the agent is given (forward from it).
|
|
205
|
+
let nextAfterBy = null;
|
|
206
|
+
for (let i = byIdx + 1; i < lcWords.length; i += 1) {
|
|
207
|
+
if (lcWords[i] === "the" || lcWords[i] === "a" || lcWords[i] === "an") continue;
|
|
208
|
+
nextAfterBy = lcWords[i]; break;
|
|
209
|
+
}
|
|
210
|
+
const agentNamed = nextAfterBy != null && !WH_WORDS.has(nextAfterBy) && !ENTITY_TO_TYPE[nextAfterBy];
|
|
211
|
+
return { shape: agentNamed ? "forward" : "reverse", entityType, modifier, kind, object };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
159
215
|
if (beforeText && afterText) return { shape: "ask", entityType: null, modifier: "direct", kind, subject: beforeText, object: afterText };
|
|
160
216
|
if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
|
|
161
217
|
// forward keeps the spotted entityType ("which modules did commit <sha> touch" is a
|
package/src/memory/blocks.mjs
CHANGED
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { splitIdentifierWords, tokenizeProse } from "../prose.mjs";
|
|
27
|
+
import { SOURCE_PRIOR } from "./trust.mjs";
|
|
28
|
+
|
|
29
|
+
// A block inherits the trust of the Source it was folded from: a session block
|
|
30
|
+
// is operator-chat (1.0), a corpus block its corpus Source (0.7). Retrieval
|
|
31
|
+
// weights relevance × trust via a BOUNDED factor (≈ 0.5 + trust → ~[0.5, 1.5]),
|
|
32
|
+
// a capped nudge so a weakly-trusted but perfectly-relevant block still surfaces.
|
|
33
|
+
const DEFAULT_BLOCK_SOURCE_TYPE = "operator";
|
|
34
|
+
const blockTrust = (sourceType) => SOURCE_PRIOR[sourceType] ?? SOURCE_PRIOR.operator;
|
|
35
|
+
const trustFactorOf = (trust) => 0.5 + (typeof trust === "number" ? trust : SOURCE_PRIOR.operator);
|
|
27
36
|
|
|
28
37
|
export const BLOCKS_DIR_REL = join(".tmct", "memory", "blocks");
|
|
29
38
|
const INDEX_NAME = "index.json";
|
|
@@ -134,14 +143,20 @@ function rerank(index) {
|
|
|
134
143
|
* duplicated (fold.mjs's re-fold idempotency rests on this).
|
|
135
144
|
* Returns the block's index entry { file, tokens, rank }.
|
|
136
145
|
*/
|
|
137
|
-
export async function saveBlock(dir, { id, text }) {
|
|
146
|
+
export async function saveBlock(dir, { id, text, sourceType = DEFAULT_BLOCK_SOURCE_TYPE, createdAt = "" }) {
|
|
138
147
|
if (!id) throw new Error("a block needs an id");
|
|
139
148
|
const bdir = blocksDir(dir);
|
|
140
149
|
await mkdir(bdir, { recursive: true });
|
|
141
150
|
const file = `${safeName(id)}.txt`;
|
|
142
151
|
await atomicWrite(join(bdir, file), String(text ?? ""));
|
|
143
152
|
const index = await loadBlockIndex(dir);
|
|
144
|
-
index.blocks[id]
|
|
153
|
+
const prior = index.blocks[id];
|
|
154
|
+
index.blocks[id] = {
|
|
155
|
+
file, tokens: tokenizeBlock(text),
|
|
156
|
+
// first-write-wins createdAt (step (a)) + the block's inherited source trust (step (d)).
|
|
157
|
+
createdAt: prior?.createdAt || createdAt || new Date().toISOString(),
|
|
158
|
+
sourceType, trust: blockTrust(sourceType),
|
|
159
|
+
};
|
|
145
160
|
rerank(index);
|
|
146
161
|
await atomicWrite(join(bdir, INDEX_NAME), JSON.stringify(index));
|
|
147
162
|
return index.blocks[id];
|
|
@@ -189,7 +204,12 @@ export async function retrieveBlocks(dir, query, k = 3) {
|
|
|
189
204
|
for (const t of qTokens) if (sets[i].has(t)) idfSum += idf.get(t);
|
|
190
205
|
if (idfSum <= 0) continue;
|
|
191
206
|
const rank = b.rank ?? 0;
|
|
192
|
-
|
|
207
|
+
// relevance × connectivity × TRUST — a bounded trustFactor (~[0.5, 1.5]) so a
|
|
208
|
+
// corroborated/operator block outranks a lone low-trust one on a relevance tie,
|
|
209
|
+
// yet a weakly-trusted but perfectly-relevant block still surfaces.
|
|
210
|
+
const trust = typeof b.trust === "number" ? b.trust : blockTrust(b.sourceType);
|
|
211
|
+
const trustFactor = trustFactorOf(trust);
|
|
212
|
+
scored.push({ id, score: idfSum * (1 + rank) * trustFactor, rank, trust, file: b.file });
|
|
193
213
|
}
|
|
194
214
|
scored.sort((a, b) => b.score - a.score || b.rank - a.rank || a.id.localeCompare(b.id));
|
|
195
215
|
const top = scored.slice(0, Math.max(1, k));
|