ai-collab-open-system 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.aict/START_HERE.md +127 -0
- package/.aict/WORKSPACE_MANIFEST.json +91 -0
- package/.aict/acceptance/EXAMPLE.synthetic.md +49 -0
- package/.aict/acceptance/FAILURE_MODES.md +40 -0
- package/.aict/acceptance/PROMPT.md +47 -0
- package/.aict/acceptance/README.md +44 -0
- package/.aict/acceptance/TEMPLATE.md +57 -0
- package/.aict/adapters/SHARED_CORE_CONTRACT.md +106 -0
- package/.aict/adapters/claude-code/ADAPTER.md +28 -0
- package/.aict/adapters/cline/ADAPTER.md +28 -0
- package/.aict/adapters/codex/ADAPTER.md +28 -0
- package/.aict/adapters/copilot/ADAPTER.md +28 -0
- package/.aict/adapters/cursor/ADAPTER.md +28 -0
- package/.aict/adapters/windsurf/ADAPTER.md +28 -0
- package/.aict/context/EXAMPLE.synthetic.md +53 -0
- package/.aict/context/FAILURE_MODES.md +40 -0
- package/.aict/context/PROMPT.md +47 -0
- package/.aict/context/README.md +44 -0
- package/.aict/context/TEMPLATE.md +63 -0
- package/.aict/cookbook/README.md +8 -0
- package/.aict/cookbook/bridge-to-a-second-family.md +103 -0
- package/.aict/cookbook/connect-a-tool.md +67 -0
- package/.aict/cookbook/review-a-half-product.md +79 -0
- package/.aict/cookbook/run-a-first-loop.md +81 -0
- package/.aict/examples/README.md +21 -0
- package/.aict/examples/ai-coding-long-task/CASE.md +161 -0
- package/.aict/examples/ai-coding-long-task/artifacts/acceptance-card.md +36 -0
- package/.aict/examples/ai-coding-long-task/artifacts/context-package.md +30 -0
- package/.aict/examples/ai-coding-long-task/artifacts/execution-prompt.md +30 -0
- package/.aict/examples/ai-coding-long-task/artifacts/first-ai-output.md +109 -0
- package/.aict/examples/ai-coding-long-task/artifacts/guard-review.md +40 -0
- package/.aict/examples/ai-coding-long-task/artifacts/handoff-note.md +28 -0
- package/.aict/examples/ai-coding-long-task/artifacts/harvest-seed.md +28 -0
- package/.aict/examples/ai-coding-long-task/artifacts/revised-output.md +62 -0
- package/.aict/examples/content-production-harvest/CASE.md +87 -0
- package/.aict/examples/content-production-harvest/artifacts/acceptance-card.md +28 -0
- package/.aict/examples/content-production-harvest/artifacts/context-package.md +28 -0
- package/.aict/examples/content-production-harvest/artifacts/execution-prompt.md +30 -0
- package/.aict/examples/content-production-harvest/artifacts/guard-review.md +28 -0
- package/.aict/examples/content-production-harvest/artifacts/handoff-note.md +28 -0
- package/.aict/examples/content-production-harvest/artifacts/harvest-seed.md +28 -0
- package/.aict/examples/multi-tool-collaboration/CASE.md +87 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/acceptance-card.md +28 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/context-package.md +28 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/execution-prompt.md +30 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/guard-review.md +28 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/handoff-note.md +28 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/harvest-seed.md +28 -0
- package/.aict/examples/personal-judgment-growth-assistant/CASE.md +87 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/acceptance-card.md +28 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/context-package.md +28 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/execution-prompt.md +30 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/guard-review.md +28 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/handoff-note.md +28 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/harvest-seed.md +28 -0
- package/.aict/examples/research-knowledge-synthesis/CASE.md +87 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/acceptance-card.md +28 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/context-package.md +28 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/execution-prompt.md +30 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/guard-review.md +28 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/handoff-note.md +28 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/harvest-seed.md +28 -0
- package/.aict/guard/EXAMPLE.synthetic.md +51 -0
- package/.aict/guard/FAILURE_MODES.md +40 -0
- package/.aict/guard/PROMPT.md +47 -0
- package/.aict/guard/README.md +44 -0
- package/.aict/guard/TEMPLATE.md +60 -0
- package/.aict/handoff/EXAMPLE.synthetic.md +51 -0
- package/.aict/handoff/FAILURE_MODES.md +40 -0
- package/.aict/handoff/PROMPT.md +47 -0
- package/.aict/handoff/README.md +44 -0
- package/.aict/handoff/TEMPLATE.md +60 -0
- package/.aict/harvest/EXAMPLE.synthetic.md +51 -0
- package/.aict/harvest/FAILURE_MODES.md +40 -0
- package/.aict/harvest/PROMPT.md +47 -0
- package/.aict/harvest/README.md +44 -0
- package/.aict/harvest/TEMPLATE.md +60 -0
- package/.aict/mechanisms/README.md +34 -0
- package/.aict/mechanisms/anti-drift-partner/EXAMPLE.synthetic.md +46 -0
- package/.aict/mechanisms/anti-drift-partner/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/anti-drift-partner/PROMPT.md +75 -0
- package/.aict/mechanisms/anti-drift-partner/README.md +82 -0
- package/.aict/mechanisms/anti-drift-partner/TEMPLATE.md +74 -0
- package/.aict/mechanisms/blind-spot-scan/EXAMPLE.synthetic.md +39 -0
- package/.aict/mechanisms/blind-spot-scan/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/blind-spot-scan/PROMPT.md +72 -0
- package/.aict/mechanisms/blind-spot-scan/README.md +79 -0
- package/.aict/mechanisms/blind-spot-scan/TEMPLATE.md +70 -0
- package/.aict/mechanisms/collaboration-coach/EXAMPLE.synthetic.md +40 -0
- package/.aict/mechanisms/collaboration-coach/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/collaboration-coach/PROMPT.md +72 -0
- package/.aict/mechanisms/collaboration-coach/README.md +79 -0
- package/.aict/mechanisms/collaboration-coach/TEMPLATE.md +61 -0
- package/.aict/mechanisms/do-not-handle-yet/EXAMPLE.synthetic.md +15 -0
- package/.aict/mechanisms/do-not-handle-yet/FAILURE_MODES.md +16 -0
- package/.aict/mechanisms/do-not-handle-yet/PROMPT.md +41 -0
- package/.aict/mechanisms/do-not-handle-yet/README.md +30 -0
- package/.aict/mechanisms/do-not-handle-yet/TEMPLATE.md +38 -0
- package/.aict/mechanisms/dual-guard/EXAMPLE.synthetic.md +54 -0
- package/.aict/mechanisms/dual-guard/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/dual-guard/PROMPT.md +76 -0
- package/.aict/mechanisms/dual-guard/README.md +81 -0
- package/.aict/mechanisms/dual-guard/TEMPLATE.md +73 -0
- package/.aict/mechanisms/feedback-absorption-ledger/EXAMPLE.synthetic.md +49 -0
- package/.aict/mechanisms/feedback-absorption-ledger/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/feedback-absorption-ledger/PROMPT.md +74 -0
- package/.aict/mechanisms/feedback-absorption-ledger/README.md +81 -0
- package/.aict/mechanisms/feedback-absorption-ledger/TEMPLATE.md +69 -0
- package/.aict/mechanisms/half-product-review/EXAMPLE.synthetic.md +15 -0
- package/.aict/mechanisms/half-product-review/FAILURE_MODES.md +16 -0
- package/.aict/mechanisms/half-product-review/PROMPT.md +41 -0
- package/.aict/mechanisms/half-product-review/README.md +30 -0
- package/.aict/mechanisms/half-product-review/TEMPLATE.md +38 -0
- package/.aict/mechanisms/handoff-abc/EXAMPLE.synthetic.md +47 -0
- package/.aict/mechanisms/handoff-abc/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/handoff-abc/PROMPT.md +75 -0
- package/.aict/mechanisms/handoff-abc/README.md +82 -0
- package/.aict/mechanisms/handoff-abc/TEMPLATE.md +60 -0
- package/.aict/mechanisms/harvest-and-erc/EXAMPLE.synthetic.md +43 -0
- package/.aict/mechanisms/harvest-and-erc/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/harvest-and-erc/PROMPT.md +74 -0
- package/.aict/mechanisms/harvest-and-erc/README.md +81 -0
- package/.aict/mechanisms/harvest-and-erc/TEMPLATE.md +60 -0
- package/.aict/mechanisms/honest-calibration/EXAMPLE.synthetic.md +43 -0
- package/.aict/mechanisms/honest-calibration/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/honest-calibration/PROMPT.md +74 -0
- package/.aict/mechanisms/honest-calibration/README.md +81 -0
- package/.aict/mechanisms/honest-calibration/TEMPLATE.md +66 -0
- package/.aict/mechanisms/one-click-dispatch/EXAMPLE.synthetic.md +15 -0
- package/.aict/mechanisms/one-click-dispatch/FAILURE_MODES.md +16 -0
- package/.aict/mechanisms/one-click-dispatch/PROMPT.md +41 -0
- package/.aict/mechanisms/one-click-dispatch/README.md +30 -0
- package/.aict/mechanisms/one-click-dispatch/TEMPLATE.md +38 -0
- package/.aict/mechanisms/plain-language-first-screen/EXAMPLE.synthetic.md +15 -0
- package/.aict/mechanisms/plain-language-first-screen/FAILURE_MODES.md +16 -0
- package/.aict/mechanisms/plain-language-first-screen/PROMPT.md +41 -0
- package/.aict/mechanisms/plain-language-first-screen/README.md +30 -0
- package/.aict/mechanisms/plain-language-first-screen/TEMPLATE.md +38 -0
- package/.aict/mechanisms/root-cause-brake/EXAMPLE.synthetic.md +55 -0
- package/.aict/mechanisms/root-cause-brake/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/root-cause-brake/PROMPT.md +73 -0
- package/.aict/mechanisms/root-cause-brake/README.md +79 -0
- package/.aict/mechanisms/root-cause-brake/TEMPLATE.md +74 -0
- package/.aict/mechanisms/scout-review-controller/EXAMPLE.synthetic.md +15 -0
- package/.aict/mechanisms/scout-review-controller/FAILURE_MODES.md +16 -0
- package/.aict/mechanisms/scout-review-controller/PROMPT.md +41 -0
- package/.aict/mechanisms/scout-review-controller/README.md +30 -0
- package/.aict/mechanisms/scout-review-controller/TEMPLATE.md +38 -0
- package/.aict/mechanisms/single-tool-guard/EXAMPLE.synthetic.md +54 -0
- package/.aict/mechanisms/single-tool-guard/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/single-tool-guard/PROMPT.md +76 -0
- package/.aict/mechanisms/single-tool-guard/README.md +83 -0
- package/.aict/mechanisms/single-tool-guard/TEMPLATE.md +75 -0
- package/.aict/mechanisms/task-splitting/EXAMPLE.synthetic.md +53 -0
- package/.aict/mechanisms/task-splitting/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/task-splitting/PROMPT.md +72 -0
- package/.aict/mechanisms/task-splitting/README.md +79 -0
- package/.aict/mechanisms/task-splitting/TEMPLATE.md +76 -0
- package/.aict/modes/README.md +11 -0
- package/.aict/modes/execute.md +31 -0
- package/.aict/modes/handoff.md +29 -0
- package/.aict/modes/harvest.md +30 -0
- package/.aict/modes/review.md +28 -0
- package/.aict/modes/shape.md +34 -0
- package/.aict/privacy/COMMERCIAL_BOUNDARY.md +34 -0
- package/.aict/privacy/PRIVACY.md +36 -0
- package/.aict/privacy/REDACTION_CHECKLIST.md +12 -0
- package/.aict/profile/CANDIDATES.md +44 -0
- package/.aict/profile/EXAMPLE.synthetic.md +49 -0
- package/.aict/profile/FAILURE_MODES.md +40 -0
- package/.aict/profile/PROMPT.md +47 -0
- package/.aict/profile/README.md +44 -0
- package/.aict/profile/TEMPLATE.md +57 -0
- package/.aict/prompts/acceptance-definition.md +109 -0
- package/.aict/prompts/guard-review.md +116 -0
- package/.aict/prompts/handoff-generation.md +110 -0
- package/.aict/prompts/harvest-extraction.md +110 -0
- package/.aict/prompts/mode-switching.md +66 -0
- package/.aict/prompts/profile-creation.md +66 -0
- package/.aict/prompts/profile-refinement.md +66 -0
- package/.aict/prompts/project-context-packaging.md +113 -0
- package/.aict/prompts/red-team-challenge.md +106 -0
- package/.aict/prompts/rule-update-proposal.md +114 -0
- package/.aict/prompts/workflow-reset.md +109 -0
- package/.aict/roles/README.md +18 -0
- package/.aict/roles/executor.md +34 -0
- package/.aict/roles/harvester.md +33 -0
- package/.aict/roles/owner-controller.md +38 -0
- package/.aict/roles/scout.md +33 -0
- package/.aict/roles/supervisor.md +34 -0
- package/.aict/roles/system-guardian.md +34 -0
- package/.aict/skills/acceptance/SKILL.md +43 -0
- package/.aict/skills/context/SKILL.md +44 -0
- package/.aict/skills/evidence-pack/SKILL.md +42 -0
- package/.aict/skills/guard/SKILL.md +46 -0
- package/.aict/skills/handoff/SKILL.md +44 -0
- package/.aict/skills/harvest/SKILL.md +44 -0
- package/.aict/skills/mode-switch/SKILL.md +42 -0
- package/.aict/skills/profile/SKILL.md +42 -0
- package/.aict/skills/red-team/SKILL.md +42 -0
- package/.aict/skills/single-tool-guard/SKILL.md +42 -0
- package/.aict/state/CURRENT_STATE.md +13 -0
- package/.aict/state/DECISIONS.md +7 -0
- package/.aict/state/TASK_LOG.md +7 -0
- package/.aict/state/evidence.jsonl +2 -0
- package/.aict/state/learning-ledger.jsonl +1 -0
- package/.aict/state/receipts.jsonl +1 -0
- package/.aict/state/runs.jsonl +1 -0
- package/.aict/state/tasks.jsonl +1 -0
- package/.aict/walkthroughs/10-minute-your-task.md +107 -0
- package/.aict/walkthroughs/10-minute.md +43 -0
- package/.aict/walkthroughs/30-minute.md +22 -0
- package/.aict/walkthroughs/60-minute.md +27 -0
- package/.aict/walkthroughs/synthetic-loop-transcript.md +43 -0
- package/CHANGELOG.md +23 -0
- package/CODE_OF_CONDUCT.md +20 -0
- package/CONTRIBUTING.md +30 -0
- package/KNOWN_LIMITATIONS.md +54 -0
- package/LICENSE +199 -0
- package/PRODUCT_CONTRACT.md +446 -0
- package/README.md +245 -0
- package/RELEASE_CHECKLIST.md +78 -0
- package/SECURITY.md +56 -0
- package/START_HERE.md +89 -0
- package/bin/ai-collab.js +2 -0
- package/docs/DOGFOOD.md +85 -0
- package/docs/FEEDBACK.md +61 -0
- package/docs/FIRST_EXPERIENCE_SPEC.md +32 -0
- package/docs/FREE_VS_PAID.md +53 -0
- package/docs/PUBLIC_BOUNDARY.md +36 -0
- package/docs/PUBLIC_MAPPING.md +178 -0
- package/docs/RELEASE_PRIORITY.md +23 -0
- package/docs/WHY_THIS_EXISTS.md +36 -0
- package/docs/open-system/00-start-here.md +60 -0
- package/docs/open-system/01-ai-collaboration-os.md +33 -0
- package/docs/open-system/02-six-layer-architecture.md +45 -0
- package/docs/open-system/03-role-system.md +33 -0
- package/docs/open-system/04-core-mechanisms.md +34 -0
- package/docs/open-system/05-failure-patterns.md +31 -0
- package/docs/open-system/06-how-to-adapt-to-your-workflow.md +31 -0
- package/package.json +69 -0
- package/privacy-manifest.json +78 -0
- package/privacy-scan.local.json.example +18 -0
- package/scripts/lib/forbidden-in-pack.js +55 -0
- package/scripts/pack-check.js +154 -0
- package/scripts/privacy-scan.js +487 -0
- package/scripts/validate-contract.js +160 -0
- package/src/adapters.js +590 -0
- package/src/bootstrap.js +1184 -0
- package/src/catalog.js +2723 -0
- package/src/cli.js +2899 -0
- package/src/dialogue.js +470 -0
- package/src/i18n.js +1034 -0
- package/src/ledger.js +2011 -0
- package/src/render.js +1381 -0
- package/src/sendmodel.js +452 -0
- package/src/validate.js +1307 -0
- package/src/workspace.js +1679 -0
- package/tests/contract.test.js +8514 -0
package/src/sendmodel.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// === semantic scan v1, EXTERNAL-MODEL HALF (opt-in, consent-gated, redacted) ==
|
|
2
|
+
//
|
|
3
|
+
// This module is the "external-model half" of semantic scanning — the ONLY feature
|
|
4
|
+
// in the whole system that can send a user's data to a model OUTSIDE this machine.
|
|
5
|
+
// Everything else (bootstrap, the dialogue LOCAL half) is deterministic + zero-network.
|
|
6
|
+
// Because this is the single privacy boundary, HONESTY IS HELD BY DESIGN here, not by
|
|
7
|
+
// good intentions: the default is "never send", and the path that DOES send is forced
|
|
8
|
+
// through consent + redaction + a low-confidence/proposed/never-written presentation.
|
|
9
|
+
//
|
|
10
|
+
// HONESTY / PRIVACY RED LINES (each one is enforced by code in this file + its tests):
|
|
11
|
+
// 1. DEFAULT NEVER SENDS. This module does nothing unless the CLI was given an
|
|
12
|
+
// EXPLICIT `--send-to-model`. With no flag, bootstrap's behavior is byte-identical
|
|
13
|
+
// to the pure-local report; none of this code runs. (Enforced in cli.js: the send
|
|
14
|
+
// path is entered only on args.sendToModel === true.)
|
|
15
|
+
// 2. CONSENT + PREVIEW BEFORE SEND. The user is shown EXACTLY what will be sent —
|
|
16
|
+
// which sources, how many snippets, that every snippet is redacted, which model —
|
|
17
|
+
// and must confirm (interactive y/N, or `--yes` when non-interactive). Without
|
|
18
|
+
// confirmation NOTHING is sent. (buildSendPreview + the cli.js gate.)
|
|
19
|
+
// 3. REDACTED PAYLOAD, ASSERTED (for the shapes we recognize). The prompt actually sent
|
|
20
|
+
// is built ONLY from snippets that already passed dialogue.js's redactSnippet, and
|
|
21
|
+
// then the WHOLE prompt is re-scanned and ASSERTED to carry no RECOGNIZED-SHAPE
|
|
22
|
+
// secret: no home-dir or /var //etc //opt //srv //usr or Windows-drive absolute path,
|
|
23
|
+
// and no recognized-shape secret (private keys incl. the multi-line body, provider
|
|
24
|
+
// api keys/tokens, the api-key/token/secret/password assignment forms, emails). This
|
|
25
|
+
// is a SHAPE allowlist, not a guarantee of total cleanliness — a secret with NO
|
|
26
|
+
// recognizable shape (e.g. a bare opaque value with no key= prefix) is not detectable
|
|
27
|
+
// and is NOT claimed clean. See redactSnippet / buildLeakDetectors for the exact
|
|
28
|
+
// covered shapes; the two are kept in sync. assertPayloadRedacted THROWS if a
|
|
29
|
+
// recognized shape slips through, so such a leak aborts the send instead of shipping.
|
|
30
|
+
// `--dry-run-send` prints this exact payload, unsent.
|
|
31
|
+
// 4. OPTIONAL MODEL CALL, GRACEFUL DEGRADE. The call is shelled out to the user's
|
|
32
|
+
// local `claude` (the prompt goes over STDIN, never argv — no injection / length
|
|
33
|
+
// blow-up), overridable via `--model <cmd>`. The invocation is an INJECTABLE
|
|
34
|
+
// function (runModel(payload, { invoke })) so tests stub it and NEVER spawn a real
|
|
35
|
+
// model. If `claude` is missing / errors / times out, we report a clear error and
|
|
36
|
+
// DEGRADE to the local result — never crash, never fabricate.
|
|
37
|
+
// 5. LLM OUTPUT IS THE LEAST-TRUSTED INPUT. The model must return JSON only. We parse
|
|
38
|
+
// it; a parse failure / unexpected shape is DISCARDED with a warning and we degrade
|
|
39
|
+
// to local — an unparsed blob is NEVER shown as a result. A parsed candidate is
|
|
40
|
+
// stamped confidence:"low" (LLM is the lowest tier), proposed:true, source:"model",
|
|
41
|
+
// and bootstrap (report-only) writes it NOWHERE (no profile, no ledger). It is
|
|
42
|
+
// NEVER rendered as done/verified.
|
|
43
|
+
//
|
|
44
|
+
// Pure + serializable + injectable: every export takes its inputs as arguments and the
|
|
45
|
+
// one side-effecting boundary (the model spawn) is a default `invoke` that callers (and
|
|
46
|
+
// tests) replace. So the whole contract is testable without ever calling an external model.
|
|
47
|
+
|
|
48
|
+
import { spawnSync } from "node:child_process";
|
|
49
|
+
import { redactSnippet } from "./dialogue.js";
|
|
50
|
+
|
|
51
|
+
// The recognized LLM candidate kinds. The model is asked to classify each finding into
|
|
52
|
+
// exactly one of these — the SAME four shapes the local half already produces, so an
|
|
53
|
+
// LLM finding can never introduce a new, unaudited card type. Anything outside this set
|
|
54
|
+
// is dropped on parse (red line #5).
|
|
55
|
+
export const MODEL_CANDIDATE_KINDS = Object.freeze([
|
|
56
|
+
"false_completion", // a "done" the chat asserts that nothing backs (-> VERIFY)
|
|
57
|
+
"profile_candidate", // a standing preference / correction (-> HARVEST profile)
|
|
58
|
+
"context_gap", // something the AI keeps missing for lack of context (-> RESUME-ish)
|
|
59
|
+
"harvest_candidate" // a reusable lesson worth carrying forward (-> HARVEST)
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// The default external model command. The prompt is delivered on STDIN to `claude -p`
|
|
63
|
+
// (headless / "print" mode): keeping the payload OFF argv avoids both shell-injection
|
|
64
|
+
// surface and OS arg-length limits on a large redacted transcript. `--model <cmd>`
|
|
65
|
+
// overrides this whole token list. Kept as an array (argv form) so the default never
|
|
66
|
+
// goes through a shell.
|
|
67
|
+
export const DEFAULT_MODEL_CMD = "claude -p";
|
|
68
|
+
|
|
69
|
+
// --- A. Payload assembly (redacted snippets only) ---------------------------
|
|
70
|
+
|
|
71
|
+
// Collect the already-redacted snippets the local dialogue scan surfaced, tagged with
|
|
72
|
+
// where each came from, so the model gets the SAME masked text the user already saw —
|
|
73
|
+
// never raw export lines. `dialogue` is the scanDialogueAndLogs() result (or null). We
|
|
74
|
+
// pull from BOTH signal lists (suspected completions + repeated corrections); each
|
|
75
|
+
// snippet was redacted by dialogue.js (redactSnippet) at extraction time. We DO NOT go
|
|
76
|
+
// back to the raw files here — the redacted snippet is the only thing that travels.
|
|
77
|
+
export function collectRedactedSnippets(dialogue) {
|
|
78
|
+
const out = [];
|
|
79
|
+
if (!dialogue || typeof dialogue !== "object") return out;
|
|
80
|
+
if (Array.isArray(dialogue.suspectedFalseCompletions)) {
|
|
81
|
+
for (const c of dialogue.suspectedFalseCompletions) {
|
|
82
|
+
if (!c || typeof c.snippet !== "string" || c.snippet.length === 0) continue;
|
|
83
|
+
out.push({
|
|
84
|
+
kind: "completion_claim",
|
|
85
|
+
source: c.source ?? null,
|
|
86
|
+
line: c.line ?? null,
|
|
87
|
+
// Defense in depth: redact AGAIN. The snippet is already redacted by
|
|
88
|
+
// dialogue.js, but re-running redactSnippet is idempotent and guarantees that
|
|
89
|
+
// even a future change upstream cannot let a raw fragment through here.
|
|
90
|
+
text: redactSnippet(c.snippet)
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(dialogue.repeatedCorrections)) {
|
|
95
|
+
for (const corr of dialogue.repeatedCorrections) {
|
|
96
|
+
if (!corr || typeof corr.snippet !== "string" || corr.snippet.length === 0) continue;
|
|
97
|
+
out.push({
|
|
98
|
+
kind: "repeated_correction",
|
|
99
|
+
count: corr.count ?? null,
|
|
100
|
+
text: redactSnippet(corr.snippet)
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The HONEST instruction block handed to the model. It does two jobs: (1) constrain the
|
|
108
|
+
// model to OUTPUT JSON ONLY in the candidate schema, and (2) bind it to the four red
|
|
109
|
+
// lines so the model cannot "help" by inventing a verified completion. The four lines
|
|
110
|
+
// are stated as hard rules the model must obey, mirroring the system's own honesty
|
|
111
|
+
// contract. Kept canonical English (this text goes to the model, not the user) — the
|
|
112
|
+
// user-facing strings are localized separately.
|
|
113
|
+
const HONESTY_DIRECTIVE = [
|
|
114
|
+
"You are reviewing REDACTED fragments from a developer's AI-collaboration chat/logs.",
|
|
115
|
+
"Your job: surface CANDIDATE signals only. You must obey these rules without exception:",
|
|
116
|
+
"1. NEVER claim anything is completed or verified. A chat saying \"done\" is NOT done; treat every completion mention as an unverified CLAIM, never a fact.",
|
|
117
|
+
"2. EVERYTHING you output is a PROPOSED candidate for a human to review — not a conclusion, not an action taken.",
|
|
118
|
+
"3. \"Said done in chat\" does not equal real completion; only a recorded, independently re-checked result would, and you cannot see or assert that.",
|
|
119
|
+
"4. You are a single model; your view is NOT an independent cross-family verification. Do not imply your read confirms anything.",
|
|
120
|
+
"",
|
|
121
|
+
"Output STRICT JSON ONLY (no prose, no markdown fences) of the form:",
|
|
122
|
+
"{ \"candidates\": [ { \"kind\": <one of: false_completion | profile_candidate | context_gap | harvest_candidate>,",
|
|
123
|
+
" \"summary\": <short string>, \"confidence\": <\"low\"|\"medium\"|\"high\">, \"basis\": <short string: what in the fragments suggests this>,",
|
|
124
|
+
" \"sourceRefs\": [<short strings referencing the fragment(s)>] } ] }",
|
|
125
|
+
"If you find nothing, output { \"candidates\": [] }. Do not output anything except this JSON object."
|
|
126
|
+
].join("\n");
|
|
127
|
+
|
|
128
|
+
// Build the EXACT prompt string that will be sent to the model (or printed by
|
|
129
|
+
// --dry-run-send). It is the honesty directive + the redacted snippets, serialized
|
|
130
|
+
// deterministically. The snippets are embedded as a JSON array of {kind,text,…} so the
|
|
131
|
+
// model sees structure but only ever the REDACTED text. The returned string is what
|
|
132
|
+
// assertPayloadRedacted re-checks and what travels on stdin — there is no second, raw
|
|
133
|
+
// version anywhere. `meta` (sources/snippetCount) is carried for the preview only and is
|
|
134
|
+
// NOT part of the prompt payload.
|
|
135
|
+
//
|
|
136
|
+
// CRITICAL: the `source` field is the on-disk path of the export the snippet came from
|
|
137
|
+
// (an absolute path under the user's home directory in the common case). That path is
|
|
138
|
+
// itself a sensitive ABSOLUTE LOCAL PATH — the SAME class redactSnippet masks and
|
|
139
|
+
// assertPayloadRedacted refuses to send. So we run the source path through redactSnippet
|
|
140
|
+
// too before embedding it. Without this, any user whose export lives under their home dir
|
|
141
|
+
// (the common case) would have EVERY real send aborted by assertPayloadRedacted (the raw
|
|
142
|
+
// home path trips the path detector), and the bare path would also be exfiltrated in the
|
|
143
|
+
// payload. Redacting it keeps the leak gate from firing on a legitimate send AND stops the
|
|
144
|
+
// path itself from travelling. (No literal home-dir prefix is written here; the release
|
|
145
|
+
// privacy scan flags real-looking local paths even inside comments.)
|
|
146
|
+
export function buildModelPrompt(snippets) {
|
|
147
|
+
const safe = Array.isArray(snippets) ? snippets : [];
|
|
148
|
+
const body = {
|
|
149
|
+
instructions: HONESTY_DIRECTIVE,
|
|
150
|
+
fragments: safe.map((s) => ({
|
|
151
|
+
kind: s.kind,
|
|
152
|
+
// Redact the source path (absolute local path = sensitive); fall back to nothing.
|
|
153
|
+
...(s.source ? { source: redactSnippet(String(s.source)) } : {}),
|
|
154
|
+
...(typeof s.line === "number" ? { line: s.line } : {}),
|
|
155
|
+
...(typeof s.count === "number" ? { count: s.count } : {}),
|
|
156
|
+
text: s.text
|
|
157
|
+
}))
|
|
158
|
+
};
|
|
159
|
+
// A human-readable directive header followed by the machine block keeps the model on
|
|
160
|
+
// task while staying a single, fully-redacted string.
|
|
161
|
+
return `${HONESTY_DIRECTIVE}\n\n--- REDACTED FRAGMENTS (JSON) ---\n${JSON.stringify(body.fragments, null, 2)}\n--- END FRAGMENTS ---\n`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- B. Redaction assertion (the hard privacy gate) -------------------------
|
|
165
|
+
//
|
|
166
|
+
// The last line of defense before ANYTHING leaves the machine. Even though every
|
|
167
|
+
// snippet is already redacted, we re-scan the WHOLE assembled prompt for the sensitive
|
|
168
|
+
// SHAPES (the same classes dialogue.js masks: private keys, provider tokens, secret
|
|
169
|
+
// assignments, emails, absolute local paths) and THROW if any survive. This converts a
|
|
170
|
+
// hypothetical leak from "silently sent" into "send aborted, loudly". Used both before a
|
|
171
|
+
// real send AND inside --dry-run-send (so the audited payload is proven clean too).
|
|
172
|
+
|
|
173
|
+
// The detection shapes. These intentionally MIRROR dialogue.js's REDACTION_RULES /
|
|
174
|
+
// scripts/privacy-scan's alwaysForbidden so "what we mask" and "what we refuse to send"
|
|
175
|
+
// are the same idea — every shape dialogue.js's redactSnippet masks is also one this gate
|
|
176
|
+
// refuses to send (private keys incl. the multi-line body, provider tokens, the
|
|
177
|
+
// api-key/token/secret/password quoted assignment, password=/passwd=/pwd= unquoted values,
|
|
178
|
+
// emails, home-dir absolute paths, the /var //etc //opt //srv //usr system paths, and
|
|
179
|
+
// Windows drive paths). We detect the PLACEHOLDER-SAFE way: a redacted prompt contains
|
|
180
|
+
// "[redacted:*]" tokens (those are fine); we look for UNMASKED sensitive shapes. The
|
|
181
|
+
// home + system path segment names + the PEM marker are assembled from fragments (not
|
|
182
|
+
// literals) so this source file itself stays clean under the release privacy scan.
|
|
183
|
+
function buildLeakDetectors() {
|
|
184
|
+
const HOME_SEGMENTS = ["Users", "home"];
|
|
185
|
+
// System roots that also make an absolute local path (kept IN SYNC with dialogue.js's
|
|
186
|
+
// buildLocalPathRules SYSTEM_ROOTS — what we mask there, we must refuse to send here).
|
|
187
|
+
const SYSTEM_ROOTS = ["var", "etc", "opt", "srv", "usr"];
|
|
188
|
+
const tail = "(?:/[^\\s`'\")]+)*";
|
|
189
|
+
const winTail = "(?:\\\\[^\\s`'\")]+)*";
|
|
190
|
+
const KEY = "PRIVATE KEY";
|
|
191
|
+
const detectors = [
|
|
192
|
+
// PEM private key: FULL block (header + base64 body) AND a lone header — mirrors the
|
|
193
|
+
// dialogue.js full-block + header-fallback rules. A surviving key body must abort too.
|
|
194
|
+
[new RegExp(`-----BEGIN (?:RSA |OPENSSH |EC |DSA )?${KEY}-----[\\s\\S]*?-----END (?:RSA |OPENSSH |EC |DSA )?${KEY}-----`, "g"), "private-key"],
|
|
195
|
+
[new RegExp(`-----BEGIN (?:RSA |OPENSSH |EC |DSA )?${KEY}-----`, "g"), "private-key"],
|
|
196
|
+
[/\bgithub_pat_[A-Za-z0-9_]{30,}/g, "token"],
|
|
197
|
+
[/\bgh[pousr]_[A-Za-z0-9_]{20,}/g, "token"],
|
|
198
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{20,}/g, "token"],
|
|
199
|
+
[/\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/gi, "token"],
|
|
200
|
+
[/sk-[A-Za-z0-9_-]{20,}/g, "secret-key"],
|
|
201
|
+
[/AKIA[0-9A-Z]{16}/g, "aws-key"],
|
|
202
|
+
[/AIza[0-9A-Za-z_-]{20,}/g, "api-key"],
|
|
203
|
+
[/\b(?:api[_-]?key|token|secret|password)\s*[:=]\s*["'][^"']{8,}["']/gi, "secret"],
|
|
204
|
+
// password=/passwd=/pwd= with an unquoted value (mirrors the dialogue.js rule of the
|
|
205
|
+
// same shape) — a clear-shape secret the quoted-only rule above misses.
|
|
206
|
+
[/\b(?:password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{4,}["']?/gi, "secret"],
|
|
207
|
+
[/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "email"]
|
|
208
|
+
];
|
|
209
|
+
for (const seg of HOME_SEGMENTS) {
|
|
210
|
+
detectors.push([new RegExp(`/${seg}/[^/\\s]+${tail}`, "g"), "path"]);
|
|
211
|
+
}
|
|
212
|
+
for (const root of SYSTEM_ROOTS) {
|
|
213
|
+
detectors.push([new RegExp(`/${root}/[^/\\s]+${tail}`, "g"), "path"]);
|
|
214
|
+
}
|
|
215
|
+
// Windows: ANY drive-letter path with at least one child segment (mirrors dialogue.js).
|
|
216
|
+
detectors.push([new RegExp(`[A-Za-z]:\\\\[^\\\\\\s]+${winTail}`, "g"), "path"]);
|
|
217
|
+
return detectors;
|
|
218
|
+
}
|
|
219
|
+
const LEAK_DETECTORS = buildLeakDetectors();
|
|
220
|
+
|
|
221
|
+
// Scan `payload` for any unmasked sensitive class; return the list of class names found
|
|
222
|
+
// (empty = clean). The "[redacted:*]" placeholders themselves never match these shapes,
|
|
223
|
+
// so an already-redacted snippet reads clean. Pure.
|
|
224
|
+
export function findSensitiveLeaks(payload) {
|
|
225
|
+
if (typeof payload !== "string" || payload.length === 0) return [];
|
|
226
|
+
const found = new Set();
|
|
227
|
+
for (const [regex, label] of LEAK_DETECTORS) {
|
|
228
|
+
regex.lastIndex = 0;
|
|
229
|
+
if (regex.test(payload)) found.add(label);
|
|
230
|
+
}
|
|
231
|
+
return [...found];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// THROW if the payload still contains any sensitive class — the hard gate that makes
|
|
235
|
+
// "redacted before send" an invariant, not a hope. Callers run this immediately before
|
|
236
|
+
// handing the payload to the model (and --dry-run-send runs it before printing), so an
|
|
237
|
+
// un-redacted payload aborts the send with a clear, do-not-ship message.
|
|
238
|
+
export function assertPayloadRedacted(payload) {
|
|
239
|
+
const leaks = findSensitiveLeaks(payload);
|
|
240
|
+
if (leaks.length > 0) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Refusing to send: the payload still contains unredacted sensitive data (${leaks.join(", ")}). Nothing was sent.`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- C. The model invocation (injectable; default = local claude over stdin) -
|
|
249
|
+
|
|
250
|
+
// The DEFAULT invoker: spawn the model command, hand the prompt on STDIN (not argv), and
|
|
251
|
+
// return { ok, stdout, stderr, code, error }. NEVER throws — any spawn failure (binary
|
|
252
|
+
// missing / non-zero exit / timeout) is reported as ok:false so the caller degrades to
|
|
253
|
+
// local. The command is parsed into argv and run WITHOUT a shell (shell:false), so even a
|
|
254
|
+
// user-supplied `--model` cannot inject shell metacharacters, and the redacted prompt on
|
|
255
|
+
// stdin is never interpolated into a command line. `cmd` is the resolved command (default
|
|
256
|
+
// DEFAULT_MODEL_CMD or the user's --model value).
|
|
257
|
+
function defaultInvoke(cmd, payload, { timeoutMs = 60000 } = {}) {
|
|
258
|
+
const tokens = String(cmd).trim().split(/\s+/).filter(Boolean);
|
|
259
|
+
if (tokens.length === 0) {
|
|
260
|
+
return { ok: false, error: "empty model command", stdout: "", stderr: "", code: null };
|
|
261
|
+
}
|
|
262
|
+
const [bin, ...rest] = tokens;
|
|
263
|
+
let res;
|
|
264
|
+
try {
|
|
265
|
+
res = spawnSync(bin, rest, {
|
|
266
|
+
input: payload, // <-- the prompt travels on STDIN, never on argv
|
|
267
|
+
encoding: "utf8",
|
|
268
|
+
timeout: timeoutMs,
|
|
269
|
+
shell: false, // no shell: the user's --model tokens cannot inject shell syntax
|
|
270
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
271
|
+
});
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return { ok: false, error: error && error.message ? error.message : String(error), stdout: "", stderr: "", code: null };
|
|
274
|
+
}
|
|
275
|
+
// spawnSync reports a missing binary / timeout via res.error (e.g. ENOENT / ETIMEDOUT).
|
|
276
|
+
if (res.error) {
|
|
277
|
+
const code = res.error.code || (res.signal ? `signal:${res.signal}` : "spawn-error");
|
|
278
|
+
return { ok: false, error: `${code}: ${res.error.message || "model invocation failed"}`, stdout: res.stdout || "", stderr: res.stderr || "", code: res.status ?? null };
|
|
279
|
+
}
|
|
280
|
+
if (res.status !== 0) {
|
|
281
|
+
return { ok: false, error: `model exited with code ${res.status}`, stdout: res.stdout || "", stderr: res.stderr || "", code: res.status };
|
|
282
|
+
}
|
|
283
|
+
return { ok: true, stdout: res.stdout || "", stderr: res.stderr || "", code: 0, error: null };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Run the model with the given (already-assembled, already-asserted) payload. `invoke`
|
|
287
|
+
// is the injection point: tests pass a stub that returns a fixed { ok, stdout } and the
|
|
288
|
+
// real model is NEVER spawned. Production passes nothing, so defaultInvoke shells out to
|
|
289
|
+
// the resolved command. This function itself does not assert redaction (the caller does,
|
|
290
|
+
// right before calling) — its sole job is "call the invoker, normalize the result".
|
|
291
|
+
export function runModel(payload, { invoke = defaultInvoke, cmd = DEFAULT_MODEL_CMD, timeoutMs = 60000 } = {}) {
|
|
292
|
+
if (typeof payload !== "string" || payload.length === 0) {
|
|
293
|
+
return { ok: false, error: "empty payload", stdout: "", stderr: "", code: null };
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const result = invoke(cmd, payload, { timeoutMs });
|
|
297
|
+
// Normalize: an invoke that forgets a field still yields a well-formed result.
|
|
298
|
+
return {
|
|
299
|
+
ok: result && result.ok === true,
|
|
300
|
+
stdout: result && typeof result.stdout === "string" ? result.stdout : "",
|
|
301
|
+
stderr: result && typeof result.stderr === "string" ? result.stderr : "",
|
|
302
|
+
code: result ? result.code ?? null : null,
|
|
303
|
+
error: result && result.ok === true ? null : (result && result.error) || "model invocation failed"
|
|
304
|
+
};
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// An injected invoke that THROWS must still degrade gracefully (never crash bootstrap).
|
|
307
|
+
return { ok: false, error: error && error.message ? error.message : String(error), stdout: "", stderr: "", code: null };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- D. Response parsing (robust; unparsed => discarded) --------------------
|
|
312
|
+
|
|
313
|
+
// Extract the first balanced top-level JSON object from a model's stdout. Models often
|
|
314
|
+
// wrap JSON in prose or ```json fences despite instructions; we tolerate that by finding
|
|
315
|
+
// the first '{' and scanning to its matching '}' (string-aware, so a brace inside a
|
|
316
|
+
// string literal does not fool it). Returns the substring or null. Pure.
|
|
317
|
+
function extractJsonObject(text) {
|
|
318
|
+
if (typeof text !== "string") return null;
|
|
319
|
+
const start = text.indexOf("{");
|
|
320
|
+
if (start === -1) return null;
|
|
321
|
+
let depth = 0;
|
|
322
|
+
let inString = false;
|
|
323
|
+
let escaped = false;
|
|
324
|
+
for (let i = start; i < text.length; i += 1) {
|
|
325
|
+
const ch = text[i];
|
|
326
|
+
if (inString) {
|
|
327
|
+
if (escaped) escaped = false;
|
|
328
|
+
else if (ch === "\\") escaped = true;
|
|
329
|
+
else if (ch === '"') inString = false;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (ch === '"') inString = true;
|
|
333
|
+
else if (ch === "{") depth += 1;
|
|
334
|
+
else if (ch === "}") {
|
|
335
|
+
depth -= 1;
|
|
336
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return null; // unbalanced => treat as unparseable
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Parse the model stdout into a list of NORMALIZED, LOW-TRUST candidates. The contract
|
|
343
|
+
// (red line #5): a parse failure OR an unexpected shape returns { ok:false, candidates:[],
|
|
344
|
+
// reason } — the caller then warns + degrades to local; an unparsed blob is NEVER turned
|
|
345
|
+
// into a candidate. On success, EVERY candidate is forcibly stamped:
|
|
346
|
+
// confidence: "low" (LLM is the least-trusted source — we OVERRIDE whatever the model
|
|
347
|
+
// said its confidence was; it does not get to self-promote)
|
|
348
|
+
// proposed: true (nothing is a conclusion)
|
|
349
|
+
// source: "model" (so a renderer/test can keep it visually + structurally apart)
|
|
350
|
+
// displayedAsDone: false (the load-bearing honesty assertion, carried explicitly)
|
|
351
|
+
// A candidate whose `kind` is not in MODEL_CANDIDATE_KINDS is DROPPED (an unaudited card
|
|
352
|
+
// type never enters). The result also reports how many raw candidates were dropped.
|
|
353
|
+
export function parseModelResponse(stdout) {
|
|
354
|
+
const fail = (reason) => ({ ok: false, candidates: [], dropped: 0, reason });
|
|
355
|
+
if (typeof stdout !== "string" || stdout.trim().length === 0) {
|
|
356
|
+
return fail("empty model output");
|
|
357
|
+
}
|
|
358
|
+
const jsonText = extractJsonObject(stdout);
|
|
359
|
+
if (!jsonText) return fail("no JSON object found in model output");
|
|
360
|
+
let parsed;
|
|
361
|
+
try {
|
|
362
|
+
parsed = JSON.parse(jsonText);
|
|
363
|
+
} catch {
|
|
364
|
+
return fail("model output was not valid JSON");
|
|
365
|
+
}
|
|
366
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.candidates)) {
|
|
367
|
+
return fail("model JSON did not contain a candidates array");
|
|
368
|
+
}
|
|
369
|
+
const candidates = [];
|
|
370
|
+
let dropped = 0;
|
|
371
|
+
for (const raw of parsed.candidates) {
|
|
372
|
+
if (!raw || typeof raw !== "object") { dropped += 1; continue; }
|
|
373
|
+
if (!MODEL_CANDIDATE_KINDS.includes(raw.kind)) { dropped += 1; continue; }
|
|
374
|
+
const summary = typeof raw.summary === "string" ? raw.summary.trim() : "";
|
|
375
|
+
if (summary.length === 0) { dropped += 1; continue; }
|
|
376
|
+
candidates.push({
|
|
377
|
+
kind: raw.kind,
|
|
378
|
+
summary,
|
|
379
|
+
basis: typeof raw.basis === "string" ? raw.basis.trim() : "",
|
|
380
|
+
sourceRefs: Array.isArray(raw.sourceRefs)
|
|
381
|
+
? raw.sourceRefs.filter((r) => typeof r === "string").slice(0, 10)
|
|
382
|
+
: [],
|
|
383
|
+
// FORCED honesty stamps — the model does not get to set these.
|
|
384
|
+
confidence: "low", // LLM source is the lowest tier, ALWAYS — never the model's claim
|
|
385
|
+
proposed: true, // never a conclusion
|
|
386
|
+
source: "model", // kept apart from ledger + local-dialogue findings
|
|
387
|
+
displayedAsDone: false // never rendered as done/verified
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return { ok: true, candidates, dropped, reason: null };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// --- E. The orchestrated send (consent already given upstream) --------------
|
|
394
|
+
|
|
395
|
+
// Build the preview the consent gate shows: WHAT will be sent, before sending. It names
|
|
396
|
+
// the sources, the snippet count, the explicit "every snippet is redacted" promise, and
|
|
397
|
+
// the model command. The CLI prints this (localized) and then asks to confirm. Returns a
|
|
398
|
+
// plain object so the CLI can render text OR --json. No side effects.
|
|
399
|
+
export function buildSendPreview({ dialogue, snippets, modelCmd }) {
|
|
400
|
+
const sources = dialogue && Array.isArray(dialogue.sources) ? dialogue.sources.map((s) => s.path) : [];
|
|
401
|
+
return {
|
|
402
|
+
sources,
|
|
403
|
+
snippetCount: Array.isArray(snippets) ? snippets.length : 0,
|
|
404
|
+
allRedacted: true, // every snippet passed redactSnippet (asserted again before send)
|
|
405
|
+
model: modelCmd || DEFAULT_MODEL_CMD
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// The full external pass, AFTER consent has been confirmed by the CLI. Steps, in order,
|
|
410
|
+
// each upholding a red line:
|
|
411
|
+
// 1. Assemble the prompt from REDACTED snippets only (collectRedactedSnippets feeds it).
|
|
412
|
+
// 2. assertPayloadRedacted(payload) — THROW (caught here -> degrade) if anything leaks.
|
|
413
|
+
// 3. runModel(payload, { invoke, cmd }) — the injectable call; tests stub `invoke`.
|
|
414
|
+
// 4. On call failure -> { ok:false, degraded:true, reason } (caller shows error + local).
|
|
415
|
+
// 5. parseModelResponse(stdout) — unparsed/odd shape -> degrade; never a junk candidate.
|
|
416
|
+
// 6. Return the LOW-confidence / proposed / source:"model" candidates (or degrade).
|
|
417
|
+
// This function NEVER writes anything and NEVER throws to the caller (a thrown redaction
|
|
418
|
+
// assertion is caught and turned into a graceful degrade with the reason surfaced).
|
|
419
|
+
export function runExternalModelPass({
|
|
420
|
+
snippets,
|
|
421
|
+
modelCmd = DEFAULT_MODEL_CMD,
|
|
422
|
+
invoke = defaultInvoke,
|
|
423
|
+
timeoutMs = 60000
|
|
424
|
+
} = {}) {
|
|
425
|
+
const payload = buildModelPrompt(snippets);
|
|
426
|
+
// Hard privacy gate. A leak aborts the SEND (we never reach runModel) and degrades.
|
|
427
|
+
try {
|
|
428
|
+
assertPayloadRedacted(payload);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
return { ok: false, degraded: true, sent: false, reason: error.message, candidates: [], payload };
|
|
431
|
+
}
|
|
432
|
+
const call = runModel(payload, { invoke, cmd: modelCmd, timeoutMs });
|
|
433
|
+
if (!call.ok) {
|
|
434
|
+
// Graceful degrade: the model was unreachable / errored / timed out. The caller
|
|
435
|
+
// keeps the local result and shows this reason; nothing is fabricated.
|
|
436
|
+
return { ok: false, degraded: true, sent: true, reason: call.error || "model call failed", candidates: [], payload };
|
|
437
|
+
}
|
|
438
|
+
const parsed = parseModelResponse(call.stdout);
|
|
439
|
+
if (!parsed.ok) {
|
|
440
|
+
// The model answered but not in the contract shape -> discard, degrade, warn.
|
|
441
|
+
return { ok: false, degraded: true, sent: true, reason: parsed.reason || "unparseable model output", candidates: [], payload };
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
ok: true,
|
|
445
|
+
degraded: false,
|
|
446
|
+
sent: true,
|
|
447
|
+
reason: null,
|
|
448
|
+
candidates: parsed.candidates, // each: low / proposed / source:"model" / displayedAsDone:false
|
|
449
|
+
dropped: parsed.dropped,
|
|
450
|
+
payload
|
|
451
|
+
};
|
|
452
|
+
}
|