headlesscode 1.0.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/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalMemoryStore — the fully working, file-backed Phase 3 memory backend.
|
|
3
|
+
*
|
|
4
|
+
* Implements the `MemoryStore` contract (src/memory/types.ts) with plain
|
|
5
|
+
* `node:fs` + JSONL — NO new dependencies. This is the harness-side
|
|
6
|
+
* implementation until the AIRunner/UwUChat endpoints exist; a future
|
|
7
|
+
* `UwUChatMemoryStore` implements the same interface over HTTP.
|
|
8
|
+
*
|
|
9
|
+
* Storage layout (under a single memory root dir, per-project scoped):
|
|
10
|
+
*
|
|
11
|
+
* <root>/facts/<project>.jsonl — one JSON `MemoryFact` per line
|
|
12
|
+
* <root>/sessions/<project>.jsonl — one JSON `SessionSummary` per line
|
|
13
|
+
*
|
|
14
|
+
* Append-only: reads parse the whole file, writes append a line. Validation is
|
|
15
|
+
* deliberately LOOSE (matching the orchestrator state-file style): malformed
|
|
16
|
+
* or partially-written lines are skipped, missing files are treated as empty.
|
|
17
|
+
*
|
|
18
|
+
* Root dir resolution order:
|
|
19
|
+
* 1. `options.dir` (constructor override — tests and the CLI pass this)
|
|
20
|
+
* 2. `process.env.HEADLESSCODE_MEMORY_DIR`
|
|
21
|
+
* 3. `<cwd>/.headlesscode/memory`
|
|
22
|
+
*
|
|
23
|
+
* Project scoping: project names are sanitized to a filesystem-safe slug and
|
|
24
|
+
* every method scopes strictly to that project's file. Project A's data can
|
|
25
|
+
* never be returned for project B.
|
|
26
|
+
*
|
|
27
|
+
* Data isolation: this store lives under the harness's own memory root and is
|
|
28
|
+
* NEVER the same storage any customer tenant route touches (see the isolation
|
|
29
|
+
* requirement in src/memory/types.ts and docs/memory-uwuchat-contract.md).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import * as fsp from "node:fs/promises"
|
|
33
|
+
import * as path from "node:path"
|
|
34
|
+
|
|
35
|
+
import { cosine, createLocalEmbedder } from "./embed.js"
|
|
36
|
+
import { buildRollingSummary } from "./summarizer.js"
|
|
37
|
+
import type { Embedder, FactInput, MemoryFact, MemoryStore, RecallResult, SessionSummary } from "./types.js"
|
|
38
|
+
|
|
39
|
+
export interface LocalMemoryStoreOptions {
|
|
40
|
+
/** Memory root dir. Default: $HEADLESSCODE_MEMORY_DIR else <cwd>/.headlesscode/memory. */
|
|
41
|
+
dir?: string
|
|
42
|
+
/** Embedder for semantic recall (default: createLocalEmbedder(256)). */
|
|
43
|
+
embedder?: Embedder
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Weight of exact tag/keyword matches in the recall score (weighted HIGH). */
|
|
47
|
+
export const KEYWORD_WEIGHT = 1.0
|
|
48
|
+
/** Weight of embedder cosine similarity in the recall score. */
|
|
49
|
+
export const SEMANTIC_WEIGHT = 0.5
|
|
50
|
+
/** Default number of recalled facts/summaries. */
|
|
51
|
+
export const DEFAULT_RECALL_LIMIT = 5
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sanitize a project name into a filesystem-safe slug used for the JSONL
|
|
55
|
+
* filename. Repo names are typically `my-repo` already; separators and other
|
|
56
|
+
* unsafe characters become `_`.
|
|
57
|
+
*/
|
|
58
|
+
export function sanitizeProject(project: string): string {
|
|
59
|
+
const cleaned = project
|
|
60
|
+
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
|
61
|
+
.replace(/^[._-]+|[._-]+$/g, "")
|
|
62
|
+
return cleaned || "default"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Deterministic content hash (hex) — used for idempotent fact dedupe. */
|
|
66
|
+
function contentHash(content: string): string {
|
|
67
|
+
const bytes = Buffer.from(content, "utf-8")
|
|
68
|
+
let hash = 0x811c9dc5
|
|
69
|
+
for (const b of bytes) {
|
|
70
|
+
hash ^= b
|
|
71
|
+
hash = Math.imul(hash, 0x01000193)
|
|
72
|
+
}
|
|
73
|
+
return (hash >>> 0).toString(16).padStart(8, "0")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readJsonl<T>(file: string): Promise<T[]> {
|
|
77
|
+
let raw: string
|
|
78
|
+
try {
|
|
79
|
+
raw = await fsp.readFile(file, "utf-8")
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
const items: T[] = []
|
|
87
|
+
for (const line of raw.split("\n")) {
|
|
88
|
+
const trimmed = line.trim()
|
|
89
|
+
if (!trimmed) {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
items.push(JSON.parse(trimmed) as T)
|
|
94
|
+
} catch {
|
|
95
|
+
// Loose validation: skip malformed / partially-written lines.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return items
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function appendJsonl(file: string, record: unknown): Promise<void> {
|
|
102
|
+
await fsp.mkdir(path.dirname(file), { recursive: true })
|
|
103
|
+
await fsp.appendFile(file, JSON.stringify(record) + "\n", "utf-8")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sortedLatest<T extends { createdAt: string }>(items: T[]): T[] {
|
|
107
|
+
return [...items].sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class LocalMemoryStore implements MemoryStore {
|
|
111
|
+
readonly dir: string
|
|
112
|
+
private readonly embedder: Embedder
|
|
113
|
+
|
|
114
|
+
constructor(options: LocalMemoryStoreOptions = {}) {
|
|
115
|
+
const resolvedDir =
|
|
116
|
+
options.dir ?? process.env.HEADLESSCODE_MEMORY_DIR ?? path.join(process.cwd(), ".headlesscode", "memory")
|
|
117
|
+
this.dir = path.resolve(resolvedDir)
|
|
118
|
+
this.embedder = options.embedder ?? createLocalEmbedder()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Path helpers (per-project scoping) ──────────────────────────────────
|
|
122
|
+
|
|
123
|
+
private projectKey(project: string): string {
|
|
124
|
+
return sanitizeProject(project)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private factsPath(projectKey: string): string {
|
|
128
|
+
return path.join(this.dir, "facts", `${projectKey}.jsonl`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private sessionsPath(projectKey: string): string {
|
|
132
|
+
return path.join(this.dir, "sessions", `${projectKey}.jsonl`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── MemoryStore implementation ──────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
async listFacts(project: string): Promise<MemoryFact[]> {
|
|
138
|
+
return readJsonl<MemoryFact>(this.factsPath(this.projectKey(project)))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async addFact(project: string, factInput: FactInput): Promise<MemoryFact> {
|
|
142
|
+
const projectKey = this.projectKey(project)
|
|
143
|
+
const file = this.factsPath(projectKey)
|
|
144
|
+
const existing = await readJsonl<MemoryFact>(file)
|
|
145
|
+
// Idempotent: dedupe by content within the project (content-hash key).
|
|
146
|
+
const duplicate = existing.find((f) => f.content === factInput.content)
|
|
147
|
+
if (duplicate) {
|
|
148
|
+
return duplicate
|
|
149
|
+
}
|
|
150
|
+
const fact: MemoryFact = {
|
|
151
|
+
id: `fact_${contentHash(factInput.content)}`,
|
|
152
|
+
project: projectKey,
|
|
153
|
+
kind: factInput.kind,
|
|
154
|
+
content: factInput.content,
|
|
155
|
+
tags: [...new Set([...(factInput.tags ?? []), factInput.kind])],
|
|
156
|
+
source: factInput.source,
|
|
157
|
+
createdAt: new Date().toISOString(),
|
|
158
|
+
}
|
|
159
|
+
await appendJsonl(file, fact)
|
|
160
|
+
return fact
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async queryRecall(project: string, query: string, limit = DEFAULT_RECALL_LIMIT): Promise<RecallResult> {
|
|
164
|
+
const projectKey = this.projectKey(project)
|
|
165
|
+
const [facts, sessions] = await Promise.all([readJsonl<MemoryFact>(this.factsPath(projectKey)), readJsonl<SessionSummary>(this.sessionsPath(projectKey))])
|
|
166
|
+
if (facts.length === 0 && sessions.length === 0) {
|
|
167
|
+
return { facts: [], summaries: [] }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const queryVec = this.embedder.embed(query)
|
|
171
|
+
const tokens = keywordTokens(query)
|
|
172
|
+
|
|
173
|
+
const scoreFact = (fact: MemoryFact): number => {
|
|
174
|
+
const keyword = keywordScore(fact.content, fact.tags, tokens)
|
|
175
|
+
const semantic = cosine(queryVec, this.embedder.embed(`${fact.kind} ${fact.content} ${fact.tags.join(" ")}`))
|
|
176
|
+
return keyword * KEYWORD_WEIGHT + semantic * SEMANTIC_WEIGHT
|
|
177
|
+
}
|
|
178
|
+
const scoreSummary = (s: SessionSummary): number => {
|
|
179
|
+
const keyword = keywordScore(`${s.task} ${s.summary}`, [], tokens)
|
|
180
|
+
const semantic = cosine(queryVec, this.embedder.embed(`${s.task} ${s.summary} ${s.mode ?? ""}`))
|
|
181
|
+
return keyword * KEYWORD_WEIGHT + semantic * SEMANTIC_WEIGHT
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const scoredFacts = facts.map((f) => ({ ...f, score: scoreFact(f) }))
|
|
185
|
+
const scoredSummaries = sessions.map((s) => ({ ...s, score: scoreSummary(s) }))
|
|
186
|
+
|
|
187
|
+
// Deterministic ordering: score desc, then createdAt desc.
|
|
188
|
+
scoredFacts.sort((a, b) => byScoreThenNewest(a, b))
|
|
189
|
+
scoredSummaries.sort((a, b) => byScoreThenNewest(a, b))
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
facts: scoredFacts.slice(0, limit),
|
|
193
|
+
summaries: scoredSummaries.slice(0, limit),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async recordSession(project: string, summary: SessionSummary): Promise<void> {
|
|
198
|
+
const projectKey = this.projectKey(project)
|
|
199
|
+
const file = this.sessionsPath(projectKey)
|
|
200
|
+
const existing = await readJsonl<SessionSummary>(file)
|
|
201
|
+
if (existing.some((s) => s.id === summary.id)) {
|
|
202
|
+
// Idempotent by id: never double-record the same session.
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
const record: SessionSummary = {
|
|
206
|
+
...summary,
|
|
207
|
+
project: projectKey,
|
|
208
|
+
id: summary.id || `session_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
|
|
209
|
+
facts: summary.facts.map((f) => ({ ...f, project: projectKey })),
|
|
210
|
+
}
|
|
211
|
+
await appendJsonl(file, record)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async listSessions(project: string): Promise<SessionSummary[]> {
|
|
215
|
+
return readJsonl<SessionSummary>(this.sessionsPath(this.projectKey(project)))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async summarize(project: string, options: { maxEntries?: number } = {}): Promise<string> {
|
|
219
|
+
const sessions = await this.listSessions(project)
|
|
220
|
+
return buildRollingSummary(sessions, options.maxEntries)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Recall scoring helpers ──────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/** Deterministic ordering: score desc, then createdAt desc. */
|
|
227
|
+
function byScoreThenNewest<T extends { score?: number; createdAt: string }>(a: T, b: T): number {
|
|
228
|
+
const scoreDiff = (b.score ?? 0) - (a.score ?? 0)
|
|
229
|
+
if (scoreDiff !== 0) {
|
|
230
|
+
return scoreDiff
|
|
231
|
+
}
|
|
232
|
+
return a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Lowercased alphanumeric tokens from the query (deduped, capped at 10). */
|
|
236
|
+
function keywordTokens(query: string): string[] {
|
|
237
|
+
const raw = query.toLowerCase().match(/[a-z0-9]+/g) ?? []
|
|
238
|
+
return [...new Set(raw)].filter((t) => t.length > 1).slice(0, 10)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Exact tag/content keyword match score (weighted HIGH per the contract):
|
|
243
|
+
* +0.4 per query token found in the content, +0.3 per token found in tags.
|
|
244
|
+
*/
|
|
245
|
+
function keywordScore(content: string, tags: string[], tokens: string[]): number {
|
|
246
|
+
const text = content.toLowerCase()
|
|
247
|
+
const tagText = tags.join(" ").toLowerCase()
|
|
248
|
+
let score = 0
|
|
249
|
+
for (const token of tokens) {
|
|
250
|
+
if (text.includes(token)) {
|
|
251
|
+
score += 0.4
|
|
252
|
+
}
|
|
253
|
+
if (tagText.includes(token)) {
|
|
254
|
+
score += 0.3
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return score
|
|
258
|
+
}
|
|
259
|
+
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session summarization — Phase 3 deterministic stand-in.
|
|
3
|
+
*
|
|
4
|
+
* `extractSessionSummary` converts a `SessionResult` (+ the session's tool-call
|
|
5
|
+
* history) into a persisted `SessionSummary`, and `buildRollingSummary` turns a
|
|
6
|
+
* list of session summaries into a compact markdown recap so a session can
|
|
7
|
+
* carry context forward WITHOUT keeping the infinite raw history (the
|
|
8
|
+
* AgentMemory equivalent).
|
|
9
|
+
*
|
|
10
|
+
* This is a DETERMINISTIC stand-in: the summary text is the session's final
|
|
11
|
+
* answer (or failure reason), files/commands are derived from the tool-call
|
|
12
|
+
* history, and facts are extracted with keyword heuristics. An LLM-based
|
|
13
|
+
* extractor (the spec's Phase 3.1 "summarize session" endpoint) can be plugged
|
|
14
|
+
* in later via the `SummarizeWithLlm` hook — NO LLM is required for the
|
|
15
|
+
* default path or for tests.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ChatMessage, LlmClient, SessionResult } from "../engine/types.js"
|
|
19
|
+
import type { MemoryFact, SessionSummary } from "./types.js"
|
|
20
|
+
|
|
21
|
+
/** Maximum number of facts extracted from one session result. */
|
|
22
|
+
export const MAX_FACTS_PER_SESSION = 8
|
|
23
|
+
/** Maximum length of an extracted fact's content. */
|
|
24
|
+
export const MAX_FACT_CHARS = 500
|
|
25
|
+
/** Default number of sessions kept in a rolling summary. */
|
|
26
|
+
export const DEFAULT_ROLLING_MAX_ENTRIES = 10
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Optional LLM-based summarization hook (Phase 3.1 placeholder).
|
|
30
|
+
*
|
|
31
|
+
* `(llmClient, transcript) => Promise<summaryText>`. Not wired into the
|
|
32
|
+
* deterministic path; documented as the swap-in point for the future
|
|
33
|
+
* AIRunner/UwUChat summarize-session endpoint.
|
|
34
|
+
*/
|
|
35
|
+
export type SummarizeWithLlm = (llmClient: LlmClient, transcript: string) => Promise<string>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Default summarizer: returns the transcript unchanged (deterministic, no
|
|
39
|
+
* LLM). Callers that want the LLM hook may implement/replace this.
|
|
40
|
+
*/
|
|
41
|
+
export class NoopSummarizer {
|
|
42
|
+
summarize(transcript: string): string {
|
|
43
|
+
return transcript
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ExtractSummaryOptions {
|
|
48
|
+
taskText: string
|
|
49
|
+
mode?: string
|
|
50
|
+
project: string
|
|
51
|
+
/**
|
|
52
|
+
* Full session message history (system/user/assistant/tool) — used to
|
|
53
|
+
* derive `filesTouched` + `commandsRun` from the executed tool calls.
|
|
54
|
+
* When omitted, those lists are empty (callers that have the history
|
|
55
|
+
* should always pass it).
|
|
56
|
+
*/
|
|
57
|
+
messages?: ChatMessage[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a `SessionSummary` from a session result + its tool history.
|
|
62
|
+
*
|
|
63
|
+
* Deterministic, no LLM required:
|
|
64
|
+
* - summary = attempt_completion result text, or the final assistant text,
|
|
65
|
+
* or the failure error message;
|
|
66
|
+
* - outcome = success | failure from `result.status`;
|
|
67
|
+
* - filesTouched = paths from write_to_file / read_file (and cwd of
|
|
68
|
+
* execute_command) calls in the message history;
|
|
69
|
+
* - commandsRun = command strings from execute_command calls;
|
|
70
|
+
* - facts = heuristic extraction from the result text (see
|
|
71
|
+
* `extractFacts`), capped at MAX_FACTS_PER_SESSION.
|
|
72
|
+
*/
|
|
73
|
+
export function extractSessionSummary(result: SessionResult, options: ExtractSummaryOptions): SessionSummary {
|
|
74
|
+
const outcome: "success" | "failure" = result.status === "success" ? "success" : "failure"
|
|
75
|
+
const summaryText =
|
|
76
|
+
(result.result ?? "").trim() || (result.error ?? "").trim() || "Task completed without a final message"
|
|
77
|
+
|
|
78
|
+
const { filesTouched, commandsRun } = deriveToolActivity(options.messages ?? [])
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
id: `summary_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
|
|
82
|
+
project: options.project,
|
|
83
|
+
task: options.taskText.trim() || "(untitled task)",
|
|
84
|
+
mode: options.mode,
|
|
85
|
+
outcome,
|
|
86
|
+
summary: summaryText.slice(0, 4000),
|
|
87
|
+
facts: extractFacts(summaryText, options),
|
|
88
|
+
filesTouched,
|
|
89
|
+
commandsRun,
|
|
90
|
+
createdAt: new Date().toISOString(),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Deterministic fact extraction from a session result text.
|
|
96
|
+
*
|
|
97
|
+
* Heuristic: split into lines, keep lines containing fact-ish keywords, map
|
|
98
|
+
* them to a kind by keyword, cap content at MAX_FACT_CHARS, dedupe by content,
|
|
99
|
+
* cap at MAX_FACTS_PER_SESSION. This is the Phase 3 stand-in for the LLM-based
|
|
100
|
+
* extractor the spec's summarize endpoint will run.
|
|
101
|
+
*/
|
|
102
|
+
export function extractFacts(text: string, options: ExtractSummaryOptions): MemoryFact[] {
|
|
103
|
+
const source = `session:${options.taskText.trim().slice(0, 80) || "extracted"}`
|
|
104
|
+
const facts: MemoryFact[] = []
|
|
105
|
+
const seen = new Set<string>()
|
|
106
|
+
|
|
107
|
+
for (const rawLine of text.split("\n")) {
|
|
108
|
+
const line = rawLine.replace(/^[-*\d.\s)\]]+\s*/, "").trim()
|
|
109
|
+
if (line.length < 12) {
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const kind = classifyLine(line)
|
|
113
|
+
if (!kind) {
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
const content = line.slice(0, MAX_FACT_CHARS)
|
|
117
|
+
if (seen.has(content)) {
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
seen.add(content)
|
|
121
|
+
facts.push({
|
|
122
|
+
id: "", // assigned/project-scoped by the store on addFact
|
|
123
|
+
project: options.project,
|
|
124
|
+
kind,
|
|
125
|
+
content,
|
|
126
|
+
tags: keywordTags(line),
|
|
127
|
+
source,
|
|
128
|
+
createdAt: new Date().toISOString(),
|
|
129
|
+
})
|
|
130
|
+
if (facts.length >= MAX_FACTS_PER_SESSION) {
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return facts
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Keyword → kind mapping (first match wins; priority: failure > decision >
|
|
139
|
+
* convention > knowledge). "Things that didn't work" map to `failure`.
|
|
140
|
+
*/
|
|
141
|
+
const FACT_KEYWORD_RULES: Array<{ kind: MemoryFact["kind"]; regex: RegExp }> = [
|
|
142
|
+
{
|
|
143
|
+
kind: "failure",
|
|
144
|
+
regex: /never|bug|break|broke|failed|doesn'?t work|didn'?t work|does not work|did not work|\bfix(ed|ing|es)?\b/i,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
kind: "decision",
|
|
148
|
+
regex: /decision|decided|chose|migration|todo|xxx|we (will|should|now) (use|keep|adopt)/i,
|
|
149
|
+
},
|
|
150
|
+
{ kind: "convention", regex: /rule|convention|always/i },
|
|
151
|
+
{ kind: "knowledge", regex: /note|remember/i },
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
export function classifyLine(line: string): MemoryFact["kind"] | null {
|
|
155
|
+
for (const rule of FACT_KEYWORD_RULES) {
|
|
156
|
+
if (rule.regex.test(line)) {
|
|
157
|
+
return rule.kind
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Tags derived from the matched keywords in a line (deduped, lowercased). */
|
|
164
|
+
function keywordTags(line: string): string[] {
|
|
165
|
+
const words = line.toLowerCase().match(/[a-z]{3,}/g) ?? []
|
|
166
|
+
const interesting = words.filter((w) =>
|
|
167
|
+
["never", "always", "rule", "convention", "bug", "fix", "migration", "decision", "note", "todo", "xxx"].includes(w),
|
|
168
|
+
)
|
|
169
|
+
return [...new Set(interesting)]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Derive filesTouched + commandsRun from the executed tool-call history.
|
|
174
|
+
*
|
|
175
|
+
* The PRIMARY source is assistant messages' `tool_calls` (the args the model
|
|
176
|
+
* emitted) — the loop stores the executor's RESULT text in `tool` messages,
|
|
177
|
+
* not the args, so they can't be parsed from there. A `tool`-message fallback
|
|
178
|
+
* (content that is itself JSON args) is kept for robustness.
|
|
179
|
+
*/
|
|
180
|
+
function deriveToolActivity(messages: ChatMessage[]): { filesTouched: string[]; commandsRun: string[] } {
|
|
181
|
+
const filesTouched: string[] = []
|
|
182
|
+
const commandsRun: string[] = []
|
|
183
|
+
const seenFiles = new Set<string>()
|
|
184
|
+
const seenCommands = new Set<string>()
|
|
185
|
+
|
|
186
|
+
const recordFile = (p: string): void => {
|
|
187
|
+
const key = p.trim()
|
|
188
|
+
if (key !== "" && !seenFiles.has(key)) {
|
|
189
|
+
seenFiles.add(key)
|
|
190
|
+
filesTouched.push(key)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const recordCommand = (c: string): void => {
|
|
194
|
+
const key = c.trim()
|
|
195
|
+
if (key !== "" && !seenCommands.has(key)) {
|
|
196
|
+
seenCommands.add(key)
|
|
197
|
+
commandsRun.push(key)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const message of messages) {
|
|
202
|
+
if (message.role === "assistant" && message.tool_calls) {
|
|
203
|
+
for (const call of message.tool_calls) {
|
|
204
|
+
const name = call.function?.name
|
|
205
|
+
let args: Record<string, unknown> = {}
|
|
206
|
+
try {
|
|
207
|
+
args = JSON.parse(call.function?.arguments ?? "") as Record<string, unknown>
|
|
208
|
+
} catch {
|
|
209
|
+
// Malformed args — best effort: skip this call.
|
|
210
|
+
}
|
|
211
|
+
recordToolActivity(name, args, recordFile, recordCommand)
|
|
212
|
+
}
|
|
213
|
+
} else if (message.role === "tool") {
|
|
214
|
+
let args: Record<string, unknown> = {}
|
|
215
|
+
try {
|
|
216
|
+
args = JSON.parse(message.content ?? "") as Record<string, unknown>
|
|
217
|
+
} catch {
|
|
218
|
+
continue // tool results carry no args (real loop shape)
|
|
219
|
+
}
|
|
220
|
+
recordToolActivity(message.name, args, recordFile, recordCommand)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { filesTouched, commandsRun }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function recordToolActivity(
|
|
227
|
+
name: string | undefined,
|
|
228
|
+
args: Record<string, unknown>,
|
|
229
|
+
recordFile: (p: string) => void,
|
|
230
|
+
recordCommand: (c: string) => void,
|
|
231
|
+
): void {
|
|
232
|
+
if (name === "write_to_file" || name === "read_file") {
|
|
233
|
+
if (typeof args["path"] === "string") {
|
|
234
|
+
recordFile(args["path"])
|
|
235
|
+
}
|
|
236
|
+
} else if (name === "execute_command") {
|
|
237
|
+
if (typeof args["command"] === "string") {
|
|
238
|
+
recordCommand(args["command"])
|
|
239
|
+
}
|
|
240
|
+
if (typeof args["cwd"] === "string") {
|
|
241
|
+
recordFile(args["cwd"])
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Compact markdown recap of the last `maxEntries` sessions (most recent
|
|
248
|
+
* first) — the AgentMemory equivalent: a session can carry this forward as
|
|
249
|
+
* context instead of the full raw history.
|
|
250
|
+
*
|
|
251
|
+
* Deterministic ordering: sessions are sorted by `createdAt` desc, then the
|
|
252
|
+
* most recent `maxEntries` are rendered.
|
|
253
|
+
*/
|
|
254
|
+
export function buildRollingSummary(sessions: SessionSummary[], maxEntries = DEFAULT_ROLLING_MAX_ENTRIES): string {
|
|
255
|
+
const sorted = [...sessions].sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
|
|
256
|
+
const recent = sorted.slice(0, maxEntries)
|
|
257
|
+
|
|
258
|
+
if (recent.length === 0) {
|
|
259
|
+
return "No prior sessions recorded for this project."
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const lines: string[] = []
|
|
263
|
+
lines.push(`### Rolling session recap (last ${recent.length} session${recent.length === 1 ? "" : "s"})`)
|
|
264
|
+
for (const session of recent) {
|
|
265
|
+
lines.push("")
|
|
266
|
+
lines.push(`**${session.createdAt}** — outcome: ${session.outcome}${session.mode ? ` (mode: ${session.mode})` : ""}`)
|
|
267
|
+
lines.push(`Task: ${session.task}`)
|
|
268
|
+
lines.push(`Summary: ${session.summary}`)
|
|
269
|
+
if (session.filesTouched.length > 0) {
|
|
270
|
+
lines.push(`Files touched: ${session.filesTouched.join(", ")}`)
|
|
271
|
+
}
|
|
272
|
+
if (session.commandsRun.length > 0) {
|
|
273
|
+
lines.push(`Commands run: ${session.commandsRun.join("; ")}`)
|
|
274
|
+
}
|
|
275
|
+
if (session.facts.length > 0) {
|
|
276
|
+
lines.push("Key facts:")
|
|
277
|
+
for (const fact of session.facts) {
|
|
278
|
+
lines.push(`- [${fact.kind}] ${fact.content}`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return lines.join("\n")
|
|
283
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3 memory subsystem — shared types + contracts.
|
|
3
|
+
*
|
|
4
|
+
* This module defines the harness's memory model: knowledge facts about a
|
|
5
|
+
* codebase (conventions, past decisions, things that didn't work), rolling
|
|
6
|
+
* session summaries, and the two abstractions the rest of the system depends
|
|
7
|
+
* on:
|
|
8
|
+
*
|
|
9
|
+
* - `MemoryStore` — the persistence/recall contract. `LocalMemoryStore`
|
|
10
|
+
* (src/memory/local.ts) is the fully working local implementation; a
|
|
11
|
+
* future `UwUChatMemoryStore` (src/memory/uwuchat.ts) implements the SAME
|
|
12
|
+
* interface against authenticated, per-project-scoped REST endpoints on
|
|
13
|
+
* the AIRunner/UwUChat side (see docs/memory-uwuchat-contract.md).
|
|
14
|
+
* - `Embedder` — the local-embedding abstraction, so a real local embedding
|
|
15
|
+
* model can be swapped in behind the same interface later. Phase 3 ships
|
|
16
|
+
* `createLocalEmbedder()` (src/memory/embed.ts), a zero-dependency,
|
|
17
|
+
* deterministic lexical-hash stand-in.
|
|
18
|
+
*
|
|
19
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
20
|
+
* DATA ISOLATION — HARD REQUIREMENT
|
|
21
|
+
*
|
|
22
|
+
* The harness memory is knowledge about the harness's OWN codebase work
|
|
23
|
+
* ("facts about a codebase and its past decisions"), NOT tenant/user data.
|
|
24
|
+
* It MUST live in a schema/storage namespace dedicated to the harness and
|
|
25
|
+
* MUST NEVER be reachable through any customer tenant route, and customer
|
|
26
|
+
* tenant data MUST NEVER be written into it. Every record is scoped by
|
|
27
|
+
* `project` (a repo name — not a tenant, not a user). Implementations must:
|
|
28
|
+
*
|
|
29
|
+
* 1. Use a dedicated store (a dedicated memory root dir for the local
|
|
30
|
+
* backend; a dedicated schema/service for the UwUChat backend) that is
|
|
31
|
+
* never the same table/store any customer-facing route reads or writes.
|
|
32
|
+
* 2. Never route harness memory through a customer tenant path, and never
|
|
33
|
+
* allow tenant-scoped queries to reach this store.
|
|
34
|
+
* 3. Never mix FHE/encryption/tenant-ownership semantics into this data —
|
|
35
|
+
* it is an internal dev tool's knowledge, not customer data.
|
|
36
|
+
*
|
|
37
|
+
* See docs/memory-uwuchat-contract.md for the full isolation guarantee.
|
|
38
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** A persisted knowledge fact about a project. */
|
|
42
|
+
export interface MemoryFact {
|
|
43
|
+
/** Stable unique id (deterministic from content for local backend). */
|
|
44
|
+
id: string
|
|
45
|
+
/** Project scope (repo name). Memory is per-project, never per-tenant. */
|
|
46
|
+
project: string
|
|
47
|
+
/**
|
|
48
|
+
* Fact category.
|
|
49
|
+
* - `convention` — rules/always-do patterns ("always run tests first")
|
|
50
|
+
* - `decision` — recorded past decisions ("we chose X over Y because …")
|
|
51
|
+
* - `failure` — things that DIDN'T work ("never use X — it breaks Y")
|
|
52
|
+
* - `knowledge` — general facts about the codebase
|
|
53
|
+
*/
|
|
54
|
+
kind: "convention" | "decision" | "failure" | "knowledge"
|
|
55
|
+
content: string
|
|
56
|
+
tags: string[]
|
|
57
|
+
/** Provenance, e.g. `session:<summary-id>` or a manual note. */
|
|
58
|
+
source?: string
|
|
59
|
+
/** ISO timestamp. */
|
|
60
|
+
createdAt: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Input for adding a fact (the store generates `id`/`project`/`createdAt`). */
|
|
64
|
+
export interface FactInput {
|
|
65
|
+
kind: MemoryFact["kind"]
|
|
66
|
+
content: string
|
|
67
|
+
tags?: string[]
|
|
68
|
+
source?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A rolling record of one completed harness session (AgentMemory equivalent). */
|
|
72
|
+
export interface SessionSummary {
|
|
73
|
+
id: string
|
|
74
|
+
project: string
|
|
75
|
+
task: string
|
|
76
|
+
mode?: string
|
|
77
|
+
/** outcome of the session: completed vs. bounded failure. */
|
|
78
|
+
outcome: "success" | "failure"
|
|
79
|
+
/** The session's final answer text (or the failure reason). */
|
|
80
|
+
summary: string
|
|
81
|
+
/** Facts extracted from this session (deterministic heuristics). */
|
|
82
|
+
facts: MemoryFact[]
|
|
83
|
+
filesTouched: string[]
|
|
84
|
+
commandsRun: string[]
|
|
85
|
+
createdAt: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A fact annotated with a relevance score from `queryRecall`. */
|
|
89
|
+
export interface ScoredFact extends MemoryFact {
|
|
90
|
+
/** Small relevance score (higher = more relevant). Absent = unscored. */
|
|
91
|
+
score?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A session summary annotated with a relevance score from `queryRecall`. */
|
|
95
|
+
export interface ScoredSessionSummary extends SessionSummary {
|
|
96
|
+
/** Small relevance score (higher = more relevant). Absent = unscored. */
|
|
97
|
+
score?: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Result of a recall query: top relevant facts + session summaries. */
|
|
101
|
+
export interface RecallResult {
|
|
102
|
+
facts: ScoredFact[]
|
|
103
|
+
summaries: ScoredSessionSummary[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The memory persistence contract — the UwUChat-backable boundary.
|
|
108
|
+
*
|
|
109
|
+
* Every method is project-scoped: callers pass the project (repo) name and
|
|
110
|
+
* implementations MUST guarantee that project A's data is never returned for
|
|
111
|
+
* project B. All methods are async (the future UwUChat backend is HTTP).
|
|
112
|
+
*
|
|
113
|
+
* A future `UwUChatMemoryStore` implements this EXACT interface against
|
|
114
|
+
* authenticated, per-project-scoped REST endpoints on the AIRunner/UwUChat
|
|
115
|
+
* side (endpoint contract documented in docs/memory-uwuchat-contract.md);
|
|
116
|
+
* `LocalMemoryStore` implements it today with plain fs + JSONL.
|
|
117
|
+
*/
|
|
118
|
+
export interface MemoryStore {
|
|
119
|
+
/** All facts stored for a project (append order). */
|
|
120
|
+
listFacts(project: string): Promise<MemoryFact[]>
|
|
121
|
+
/** Add a fact; MUST be idempotent (dedupe by content within a project). */
|
|
122
|
+
addFact(project: string, factInput: FactInput): Promise<MemoryFact>
|
|
123
|
+
/**
|
|
124
|
+
* Semantic recall over facts + session summaries for a project.
|
|
125
|
+
* Returns the top-`limit` most relevant facts and summaries, each with a
|
|
126
|
+
* small relevance score, ordered deterministically (score desc, then
|
|
127
|
+
* createdAt desc). Must work with an empty store (returns empty results).
|
|
128
|
+
*/
|
|
129
|
+
queryRecall(project: string, query: string, limit?: number): Promise<RecallResult>
|
|
130
|
+
/** Persist a completed session summary for a project. */
|
|
131
|
+
recordSession(project: string, summary: SessionSummary): Promise<void>
|
|
132
|
+
/** All session summaries stored for a project (append order). */
|
|
133
|
+
listSessions(project: string): Promise<SessionSummary[]>
|
|
134
|
+
/**
|
|
135
|
+
* Optional: a compact rolling recap (markdown) of the last N sessions for
|
|
136
|
+
* a project, so a session can carry context forward WITHOUT keeping the
|
|
137
|
+
* full raw history. Implementations that provide it can be used directly
|
|
138
|
+
* by the loop; otherwise the loop derives one from `queryRecall`.
|
|
139
|
+
*/
|
|
140
|
+
summarize?(project: string, options?: { maxEntries?: number }): Promise<string>
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The local embedding abstraction.
|
|
145
|
+
*
|
|
146
|
+
* Phase 3 ships `createLocalEmbedder()` (a deterministic, dependency-free
|
|
147
|
+
* lexical-hash stand-in). A real local embedding model can be swapped in
|
|
148
|
+
* behind this same interface later without touching any caller.
|
|
149
|
+
*/
|
|
150
|
+
export interface Embedder {
|
|
151
|
+
/** Embed a text into a fixed-dimension L2-normalized vector. */
|
|
152
|
+
embed(text: string): number[]
|
|
153
|
+
}
|