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.
Files changed (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. 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
+ }