fuli-context 0.4.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +5 -0
- package/README.md +411 -173
- package/README.zh-CN.md +448 -0
- package/compose.graphiti.yml +6 -6
- package/dist/web/assets/AboutPage-DXh7Fwxt.css +1 -0
- package/dist/web/assets/AboutPage-DfnZo7Lo.js +1 -0
- package/dist/web/assets/ConnectionsPage-BloaSKmt.js +1 -0
- package/dist/web/assets/ConnectionsPage-CBHAdZXT.css +1 -0
- package/dist/web/assets/KnowledgeInspector-Bjhdu2mf.css +1 -0
- package/dist/web/assets/KnowledgeInspector-QvNpv0_k.js +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-Cx1w1jb2.css +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-DKq1jVD5.js +1 -0
- package/dist/web/assets/KnowledgePage-BWL0jT01.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-Czq6aZ7b.js +4 -0
- package/dist/web/assets/OverviewPage-5sJoO1Gh.js +1 -0
- package/dist/web/assets/{PersonalProfilePage-S8ZcG2LU.css → PersonalProfilePage-CdmEzyvZ.css} +1 -1
- package/dist/web/assets/PersonalProfilePage-tVChM9ik.js +1 -0
- package/dist/web/assets/PersonalProjectsPage-BPFrup4V.js +1 -0
- package/dist/web/assets/PublicProjectsPage-BPJSeREN.js +1 -0
- package/dist/web/assets/ReviewPage-B7EN817i.js +1 -0
- package/dist/web/assets/SearchableSelect-ClLN0rIb.js +1 -0
- package/dist/web/assets/SettingsPage-C6ouU0sR.css +1 -0
- package/dist/web/assets/SettingsPage-Chd_B3eH.js +1 -0
- package/dist/web/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
- package/dist/web/assets/console-CbDsKrsO.js +1 -0
- package/dist/web/assets/fuli-logo-Bu9WBuQe.js +1 -0
- package/dist/web/assets/fuli-logo-ER1wQ8Kk.png +0 -0
- package/dist/web/assets/i18n-BQcns1PH.js +5 -0
- package/dist/web/assets/identity-B8K4VYg6.js +1 -0
- package/dist/web/assets/index-CB8Xmsc2.js +2 -0
- package/dist/web/assets/index-CZeuPh-U.css +1 -0
- package/dist/web/assets/model-eF52GgPF.js +1 -0
- package/dist/web/assets/paths-hwz-Ac1g.js +1 -0
- package/dist/web/assets/useMinimumLoadingDisplay-BpCyBeko.css +1 -0
- package/dist/web/assets/useMinimumLoadingDisplay-wCgGDmHg.js +1 -0
- package/dist/web/assets/vue-router-BsEp_XwI.js +1 -0
- package/dist/web/index.html +9 -6
- package/docs/external-knowledge-architecture.md +279 -0
- package/examples/external-knowledge/markdown-folder.mjs +235 -0
- package/graph-provider/fuli_graph/app.py +57 -1
- package/graph-provider/fuli_graph/graph_models.py +3 -0
- package/graph-provider/fuli_graph/graph_query.py +62 -16
- package/graph-provider/fuli_graph/knowledge_review.py +612 -0
- package/graph-provider/fuli_graph/knowledge_review_models.py +127 -0
- package/graph-provider/fuli_graph/knowledge_search.py +51 -4
- package/graph-provider/fuli_graph/models.py +19 -0
- package/graph-provider/fuli_graph/runtime.py +15 -0
- package/graph-provider/fuli_graph/store_knowledge.py +2 -1
- package/graph-provider/pyproject.toml +1 -1
- package/npm-shrinkwrap.json +5 -407
- package/package.json +7 -4
- package/skills/capturing-session-knowledge/SKILL.md +24 -4
- package/skills/flreview/SKILL.md +73 -0
- package/skills/flreview/agents/openai.yaml +6 -0
- package/src/agent-tools/graph-definitions.js +79 -4
- package/src/agent-tools/graph-handlers.js +12 -0
- package/src/cli/command-registry.js +3 -3
- package/src/cli/local-runtime-command.js +60 -23
- package/src/cli/local-runtime-options.js +16 -6
- package/src/cli/setup-agent-selection.js +12 -12
- package/src/cli/setup-command.js +41 -18
- package/src/cli/shell-argument.js +10 -0
- package/src/cli/uninstall-command.js +17 -15
- package/src/cli/update-command.js +38 -30
- package/src/external-knowledge/catalog.js +61 -0
- package/src/external-knowledge/conflict-policy.js +58 -0
- package/src/external-knowledge/connected-search.js +166 -0
- package/src/external-knowledge/connectors/custom.js +133 -0
- package/src/external-knowledge/connectors/feishu.js +461 -0
- package/src/external-knowledge/connectors/mcp.js +438 -0
- package/src/external-knowledge/connectors/notion.js +307 -0
- package/src/external-knowledge/connectors/retrieval-api.js +222 -0
- package/src/external-knowledge/document-mapping.js +151 -0
- package/src/external-knowledge/graph-projection.js +17 -0
- package/src/external-knowledge/index.js +8 -0
- package/src/external-knowledge/registry.js +91 -0
- package/src/external-knowledge/runtime.js +68 -0
- package/src/external-knowledge/safe-diagnostic.js +14 -0
- package/src/external-knowledge/service.js +811 -0
- package/src/graphiti/agent-knowledge-workflows.js +7 -2
- package/src/graphiti/federated-application.js +66 -41
- package/src/graphiti/knowledge-item-role.js +21 -0
- package/src/graphiti/knowledge-review.js +43 -0
- package/src/graphiti/project-profile-mapping.js +32 -0
- package/src/graphiti/provider-client.js +22 -1
- package/src/graphiti/source-marker.js +54 -3
- package/src/graphiti/task-knowledge-recall.js +250 -0
- package/src/http/api-router.js +23 -108
- package/src/http/external-knowledge-api-router.js +104 -0
- package/src/http/graph-api-router.js +12 -1
- package/src/http/request-policy.js +38 -3
- package/src/local-runtime/lifecycle.js +37 -16
- package/src/mcp/create-mcp-server.js +1 -0
- package/src/mcp/instructions.js +7 -7
- package/src/mcp/tool-annotations.js +6 -0
- package/src/mcp/tool-result.js +1 -0
- package/src/server/application-lifecycle.js +2 -11
- package/src/server/blocked-ports.js +11 -0
- package/src/server/lan-access.js +52 -0
- package/src/server.js +103 -25
- package/src/setup/agent-installation-status.js +2 -1
- package/src/setup/agents.js +2 -1
- package/src/setup/claude-code-config.js +2 -1
- package/src/setup/codex-bootstrap.js +12 -7
- package/src/setup/container-runtime.js +25 -21
- package/src/setup/graph-runtime.js +219 -58
- package/src/setup/node-runtime.js +3 -3
- package/src/setup/options.js +2 -4
- package/src/setup/paths.js +18 -3
- package/src/setup/review-skill-adapters.js +49 -0
- package/src/setup/setup.js +26 -2
- package/src/setup/uninstall.js +4 -2
- package/src/system/resource-monitor.js +317 -0
- package/src/system/runtime-settings.js +131 -0
- package/src/system/system-service.js +119 -0
- package/dist/web/assets/ConnectionsPage-CCKWm-6p.js +0 -1
- package/dist/web/assets/KnowledgeInspector-BZkB2wJn.js +0 -1
- package/dist/web/assets/KnowledgeInspector-CrGYxEob.css +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-CJCNI2Wd.css +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-PXH-TlGt.js +0 -1
- package/dist/web/assets/KnowledgePage-CNQXd15y.js +0 -1
- package/dist/web/assets/KnowledgeWorkspace-CXPE4GCD.js +0 -4
- package/dist/web/assets/OverviewPage-ul7g7HXh.js +0 -1
- package/dist/web/assets/PersonalProfilePage-CC40Pw2L.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-CPIEcShD.js +0 -1
- package/dist/web/assets/PublicProjectsPage-C700sR7z.js +0 -1
- package/dist/web/assets/ReviewPage-7MDDUDgA.js +0 -1
- package/dist/web/assets/console-B_Ena2DS.js +0 -5
- package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
- package/dist/web/assets/identity-CjBwYSMX.js +0 -1
- package/dist/web/assets/index-BQRkOwPx.js +0 -2
- package/dist/web/assets/index-CpLDx3of.css +0 -1
- package/dist/web/assets/model-C-5RVIJ2.js +0 -1
- package/dist/web/assets/paths-BkULqmGI.js +0 -1
- package/dist/web/assets/vue-router-DnD3hUm7.js +0 -1
- package/src/agent/candidate-read-service.js +0 -53
- package/src/agent/context-pack-service.js +0 -83
- package/src/agent/public-read-policy.js +0 -39
- package/src/agent/public-read-service.js +0 -69
- package/src/agent/read-projection.js +0 -107
- package/src/agent/search-service.js +0 -61
- package/src/agent/space-boundary.js +0 -33
- package/src/app/bootstrap.js +0 -8
- package/src/app/create-application.js +0 -143
- package/src/app/state-service.js +0 -38
- package/src/candidates.js +0 -78
- package/src/classifier.js +0 -63
- package/src/cli/command-arguments.js +0 -14
- package/src/context-pack.js +0 -206
- package/src/extractor.js +0 -77
- package/src/fact-history.js +0 -39
- package/src/facts/fact-service.js +0 -89
- package/src/http/bounded-json-array.js +0 -19
- package/src/http/lens-route.js +0 -50
- package/src/ingestion.js +0 -161
- package/src/lens/lens-correction-service.js +0 -81
- package/src/lens/lens-fact-writer.js +0 -85
- package/src/lens/lens-input.js +0 -62
- package/src/lens/lens-query-projection.js +0 -87
- package/src/lens/lens-query-ranking.js +0 -62
- package/src/lens/lens-query.js +0 -143
- package/src/lens/lens-resource-projection.js +0 -93
- package/src/lens/lens-resource-selection.js +0 -45
- package/src/lens/lens-resource-service.js +0 -103
- package/src/lens/lens-search-query.js +0 -122
- package/src/lens/lens-service.js +0 -183
- package/src/models.js +0 -44
- package/src/observer.js +0 -83
- package/src/project-rules.js +0 -33
- package/src/publication/outbox-service.js +0 -46
- package/src/publication/publication-envelope-schema.js +0 -75
- package/src/publication/publication-envelope.js +0 -37
- package/src/publication/publication-policy.js +0 -82
- package/src/publication/publication-service.js +0 -48
- package/src/router.js +0 -58
- package/src/runtime-options.js +0 -138
- package/src/security/episode-visibility.js +0 -14
- package/src/security/fact-visibility.js +0 -15
- package/src/security/source-metadata-policy.js +0 -8
- package/src/storage/episode-evidence-preview.js +0 -100
- package/src/storage/fact-search-order.js +0 -18
- package/src/storage/file/episode-evidence.js +0 -37
- package/src/storage/file/episode-index.js +0 -59
- package/src/storage/file/fact-search.js +0 -60
- package/src/storage/file-store.js +0 -437
- package/src/storage/migrate.js +0 -128
- package/src/storage/migrations/001-initial.sql +0 -83
- package/src/storage/migrations/002-storage-integrity.sql +0 -113
- package/src/storage/record-validation.js +0 -46
- package/src/storage/snapshot-defaults.js +0 -54
- package/src/storage/sqlite/candidates-repository.js +0 -84
- package/src/storage/sqlite/episode-evidence-repository.js +0 -154
- package/src/storage/sqlite/episode-privacy-sql.js +0 -33
- package/src/storage/sqlite/episodes-repository.js +0 -52
- package/src/storage/sqlite/fact-search-repository.js +0 -162
- package/src/storage/sqlite/facts-repository.js +0 -146
- package/src/storage/sqlite/imports-repository.js +0 -39
- package/src/storage/sqlite/mapper.js +0 -84
- package/src/storage/sqlite/open-database.js +0 -15
- package/src/storage/sqlite/outbox-repository.js +0 -103
- package/src/storage/sqlite/snapshot-repository.js +0 -69
- package/src/storage/sqlite/spaces-repository.js +0 -52
- package/src/storage/sqlite/subscriptions-repository.js +0 -54
- package/src/storage/sqlite-store.js +0 -159
- package/src/storage/stable-json.js +0 -24
- package/src/storage/store-port.js +0 -20
- package/src/store.js +0 -2
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { SpaceKind } from '../models.js';
|
|
2
|
-
import {
|
|
3
|
-
isSafeResourceFact,
|
|
4
|
-
isSafeResourceText,
|
|
5
|
-
projectCurrentFact,
|
|
6
|
-
projectHistoryItem,
|
|
7
|
-
projectSubscription
|
|
8
|
-
} from './lens-resource-projection.js';
|
|
9
|
-
import { selectCurrentFacts, selectHistoryItems } from './lens-resource-selection.js';
|
|
10
|
-
|
|
11
|
-
export const CURRENT_LENS_BUDGET_BYTES = 16 * 1024;
|
|
12
|
-
export const HISTORY_BUDGET_BYTES = 64 * 1024;
|
|
13
|
-
export const HISTORY_LIMIT = 100;
|
|
14
|
-
|
|
15
|
-
export class LensResourceService {
|
|
16
|
-
constructor({ store, lens, activePersonalSpace }) {
|
|
17
|
-
this.store = store;
|
|
18
|
-
this.lens = lens;
|
|
19
|
-
this.activePersonalSpace = activePersonalSpace;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
current() {
|
|
23
|
-
const personal = this.activePersonalSpace();
|
|
24
|
-
const result = this.lens.getUserLens({
|
|
25
|
-
personalSpaceId: personal.id,
|
|
26
|
-
task: '',
|
|
27
|
-
budget: CURRENT_LENS_BUDGET_BYTES,
|
|
28
|
-
includeObserved: true,
|
|
29
|
-
includeSuggested: false,
|
|
30
|
-
includeRestricted: false
|
|
31
|
-
});
|
|
32
|
-
const projected = result.facts.map(projectCurrentFact).filter(Boolean);
|
|
33
|
-
const selected = selectCurrentFacts(projected, CURRENT_LENS_BUDGET_BYTES);
|
|
34
|
-
return safePayload({
|
|
35
|
-
personalSpaceId: personal.id,
|
|
36
|
-
facts: selected.facts,
|
|
37
|
-
text: selected.text,
|
|
38
|
-
truncated: result.truncated ||
|
|
39
|
-
projected.length < result.facts.length ||
|
|
40
|
-
selected.truncated,
|
|
41
|
-
budget: {
|
|
42
|
-
encoding: 'utf-8',
|
|
43
|
-
limitBytes: CURRENT_LENS_BUDGET_BYTES,
|
|
44
|
-
usedBytes: Buffer.byteLength(selected.text, 'utf8')
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
history() {
|
|
50
|
-
const personal = this.activePersonalSpace();
|
|
51
|
-
const result = this.lens.searchUserContext({
|
|
52
|
-
personalSpaceId: personal.id,
|
|
53
|
-
query: '',
|
|
54
|
-
includeHistorical: true,
|
|
55
|
-
includeRestricted: false,
|
|
56
|
-
maxFactObjectBytes: HISTORY_BUDGET_BYTES,
|
|
57
|
-
maxEvidenceBodyBytes: 0,
|
|
58
|
-
scanLimit: HISTORY_LIMIT * 2,
|
|
59
|
-
factFilter: isSafeResourceFact,
|
|
60
|
-
limit: HISTORY_LIMIT
|
|
61
|
-
});
|
|
62
|
-
const safeItems = result.facts.map(projectHistoryItem).filter(Boolean);
|
|
63
|
-
const selected = selectHistoryItems(safeItems, {
|
|
64
|
-
limit: HISTORY_LIMIT,
|
|
65
|
-
budgetBytes: HISTORY_BUDGET_BYTES
|
|
66
|
-
});
|
|
67
|
-
return safePayload({
|
|
68
|
-
personalSpaceId: personal.id,
|
|
69
|
-
limit: HISTORY_LIMIT,
|
|
70
|
-
count: selected.items.length,
|
|
71
|
-
truncated: result.truncated ||
|
|
72
|
-
safeItems.length < result.facts.length ||
|
|
73
|
-
selected.truncated,
|
|
74
|
-
budget: {
|
|
75
|
-
encoding: 'utf-8',
|
|
76
|
-
limitBytes: HISTORY_BUDGET_BYTES,
|
|
77
|
-
itemsBytes: selected.itemsBytes
|
|
78
|
-
},
|
|
79
|
-
items: selected.items
|
|
80
|
-
}, HISTORY_BUDGET_BYTES);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
subscribed() {
|
|
84
|
-
const personal = this.activePersonalSpace();
|
|
85
|
-
const subscriptions = this.store.subscriptionsFor(personal.id)
|
|
86
|
-
.map((subscription) => ({ subscription, space: this.store.getSpace(subscription.spaceId) }))
|
|
87
|
-
.filter(({ space }) => space?.kind === SpaceKind.PUBLIC)
|
|
88
|
-
.map(({ subscription, space }) => projectSubscription(subscription, space))
|
|
89
|
-
.filter(Boolean);
|
|
90
|
-
return safePayload({ personalSpaceId: personal.id, subscriptions });
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function safePayload(payload, limitBytes = null) {
|
|
95
|
-
const serialized = JSON.stringify(payload);
|
|
96
|
-
if (!isSafeResourceText(serialized)) {
|
|
97
|
-
throw new Error('Unsafe resource projection');
|
|
98
|
-
}
|
|
99
|
-
if (limitBytes && Buffer.byteLength(serialized, 'utf8') > limitBytes) {
|
|
100
|
-
throw new Error('Resource projection exceeds its byte budget');
|
|
101
|
-
}
|
|
102
|
-
return payload;
|
|
103
|
-
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { ApplicationError, ApplicationErrorCode } from '../app/application-error.js';
|
|
2
|
-
import { FactScope } from '../models.js';
|
|
3
|
-
import { MAX_EPISODE_EVIDENCE_BODY_BYTES } from '../storage/episode-evidence-preview.js';
|
|
4
|
-
import { assertStorePort } from '../storage/store-port.js';
|
|
5
|
-
import { requirePersonalSpace } from './lens-input.js';
|
|
6
|
-
import {
|
|
7
|
-
buildCorrectionIndex,
|
|
8
|
-
isSearchableFact,
|
|
9
|
-
projectSearchFact
|
|
10
|
-
} from './lens-query-projection.js';
|
|
11
|
-
|
|
12
|
-
export const DEFAULT_SEARCH_USER_CONTEXT_LIMIT = 20;
|
|
13
|
-
export const MAX_SEARCH_USER_CONTEXT_LIMIT = 100;
|
|
14
|
-
const MAX_SEARCH_USER_CONTEXT_SCAN_LIMIT = 200;
|
|
15
|
-
|
|
16
|
-
export class LensSearchQuery {
|
|
17
|
-
constructor(store) {
|
|
18
|
-
assertStorePort(store);
|
|
19
|
-
this.store = store;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
searchUserContext({
|
|
23
|
-
personalSpaceId,
|
|
24
|
-
query,
|
|
25
|
-
includeHistorical = false,
|
|
26
|
-
includeRestricted = false,
|
|
27
|
-
requireSafeSource = false,
|
|
28
|
-
maxFactObjectBytes = null,
|
|
29
|
-
maxEvidenceBodyBytes = MAX_EPISODE_EVIDENCE_BODY_BYTES,
|
|
30
|
-
limit = DEFAULT_SEARCH_USER_CONTEXT_LIMIT,
|
|
31
|
-
scanLimit = limit,
|
|
32
|
-
factFilter = null
|
|
33
|
-
}) {
|
|
34
|
-
requirePersonalSpace(this.store, personalSpaceId);
|
|
35
|
-
validateQuery(query);
|
|
36
|
-
validateSearchLimit(limit);
|
|
37
|
-
validateScanOptions(scanLimit, limit, factFilter);
|
|
38
|
-
|
|
39
|
-
const allowRestricted = includeRestricted === true;
|
|
40
|
-
const page = this.store.searchFactsPage([personalSpaceId], query, {
|
|
41
|
-
includeHistorical,
|
|
42
|
-
scope: FactScope.PERSONAL,
|
|
43
|
-
excludeRestricted: !allowRestricted,
|
|
44
|
-
safeSourceSpaceId: requireSafeSource === true ? personalSpaceId : null,
|
|
45
|
-
maxObjectBytes: maxFactObjectBytes,
|
|
46
|
-
limit: scanLimit + 1
|
|
47
|
-
});
|
|
48
|
-
const candidates = page.facts;
|
|
49
|
-
const scanned = candidates.slice(0, scanLimit);
|
|
50
|
-
const facts = scanned
|
|
51
|
-
.filter((fact) => fact.spaceId === personalSpaceId && fact.scope === FactScope.PERSONAL)
|
|
52
|
-
.filter((fact) => isSearchableFact(fact, {
|
|
53
|
-
includeHistorical,
|
|
54
|
-
includeRestricted: allowRestricted
|
|
55
|
-
}))
|
|
56
|
-
.filter((fact) => factFilter?.(fact) ?? true);
|
|
57
|
-
const selected = facts.slice(0, limit);
|
|
58
|
-
const correctionIndex = buildCorrectionIndex(
|
|
59
|
-
this.store.correctionEpisodeEvidencePreviews(
|
|
60
|
-
personalSpaceId,
|
|
61
|
-
selected.map((fact) => fact.id),
|
|
62
|
-
{ includeRestricted: allowRestricted, maxBodyBytes: maxEvidenceBodyBytes }
|
|
63
|
-
),
|
|
64
|
-
personalSpaceId,
|
|
65
|
-
allowRestricted
|
|
66
|
-
);
|
|
67
|
-
return {
|
|
68
|
-
personalSpaceId,
|
|
69
|
-
query,
|
|
70
|
-
truncated: page.objectLimitTruncated ||
|
|
71
|
-
candidates.length > scanLimit ||
|
|
72
|
-
facts.length > limit ||
|
|
73
|
-
facts.length < scanned.length,
|
|
74
|
-
facts: selected.map((fact) => {
|
|
75
|
-
const source = this.store.episodeEvidencePreview(
|
|
76
|
-
personalSpaceId,
|
|
77
|
-
fact.sourceEpisodeId,
|
|
78
|
-
{ includeRestricted: allowRestricted, maxBodyBytes: maxEvidenceBodyBytes }
|
|
79
|
-
);
|
|
80
|
-
return projectSearchFact(
|
|
81
|
-
this.store,
|
|
82
|
-
fact,
|
|
83
|
-
source,
|
|
84
|
-
personalSpaceId,
|
|
85
|
-
correctionIndex,
|
|
86
|
-
allowRestricted
|
|
87
|
-
);
|
|
88
|
-
})
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function validateScanOptions(scanLimit, limit, factFilter) {
|
|
94
|
-
if (
|
|
95
|
-
!Number.isInteger(scanLimit) ||
|
|
96
|
-
scanLimit < limit ||
|
|
97
|
-
scanLimit > MAX_SEARCH_USER_CONTEXT_SCAN_LIMIT
|
|
98
|
-
) {
|
|
99
|
-
throw new ApplicationError(
|
|
100
|
-
ApplicationErrorCode.VALIDATION,
|
|
101
|
-
`Scan limit must be an integer between the result limit and ${MAX_SEARCH_USER_CONTEXT_SCAN_LIMIT}`
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
if (factFilter !== null && typeof factFilter !== 'function') {
|
|
105
|
-
throw new ApplicationError(ApplicationErrorCode.VALIDATION, 'Fact filter must be a function');
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function validateQuery(query) {
|
|
110
|
-
if (typeof query !== 'string') {
|
|
111
|
-
throw new ApplicationError(ApplicationErrorCode.VALIDATION, 'Query must be a string');
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function validateSearchLimit(limit) {
|
|
116
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_USER_CONTEXT_LIMIT) {
|
|
117
|
-
throw new ApplicationError(
|
|
118
|
-
ApplicationErrorCode.VALIDATION,
|
|
119
|
-
`Limit must be an integer between 1 and ${MAX_SEARCH_USER_CONTEXT_LIMIT}`
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
}
|
package/src/lens/lens-service.js
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import { ApplicationError, ApplicationErrorCode } from '../app/application-error.js';
|
|
2
|
-
import { FactScope, FactStatus, Sensitivity } from '../models.js';
|
|
3
|
-
import { assertSafeSourceMetadata } from '../security/source-metadata-policy.js';
|
|
4
|
-
import { assertStorePort } from '../storage/store-port.js';
|
|
5
|
-
import { writePersonalFact, writePersonalFactSpecs } from './lens-fact-writer.js';
|
|
6
|
-
import { assertSafeLensTexts, requirePersonalSpace, validateLensWriteInput } from './lens-input.js';
|
|
7
|
-
import { LensCorrectionService } from './lens-correction-service.js';
|
|
8
|
-
const OBSERVATION_INFERENCES = new Set(['direct', 'inferred']);
|
|
9
|
-
const IMMEDIATE = { mode: 'immediate' };
|
|
10
|
-
const SOURCE_METADATA_ERROR = 'Sensitive source metadata is not allowed in Personal Lens';
|
|
11
|
-
|
|
12
|
-
export class LensService {
|
|
13
|
-
#corrections;
|
|
14
|
-
|
|
15
|
-
constructor(store) {
|
|
16
|
-
assertStorePort(store);
|
|
17
|
-
this.store = store;
|
|
18
|
-
this.#corrections = new LensCorrectionService(store);
|
|
19
|
-
}
|
|
20
|
-
rememberUserFact({
|
|
21
|
-
personalSpaceId,
|
|
22
|
-
subject = 'user',
|
|
23
|
-
predicate,
|
|
24
|
-
value,
|
|
25
|
-
sourceText,
|
|
26
|
-
sourceKind = 'conversation',
|
|
27
|
-
sensitivity = Sensitivity.NORMAL,
|
|
28
|
-
confidence = 1
|
|
29
|
-
}) {
|
|
30
|
-
assertSafeSourceMetadata(sourceKind, null, SOURCE_METADATA_ERROR);
|
|
31
|
-
this.#validate(personalSpaceId, [subject, predicate, value, sourceText], sensitivity, confidence);
|
|
32
|
-
return this.store.transaction(() => {
|
|
33
|
-
const episode = this.store.addEpisode(personalSpaceId, sourceKind, sourceText);
|
|
34
|
-
const fact = this.#writeFact({
|
|
35
|
-
personalSpaceId, subject, predicate, value, episode,
|
|
36
|
-
status: FactStatus.CONFIRMED, sensitivity, confidence
|
|
37
|
-
});
|
|
38
|
-
return { episode, fact };
|
|
39
|
-
}, IMMEDIATE);
|
|
40
|
-
}
|
|
41
|
-
rememberUserFactsFromSourceInCurrentTransaction({
|
|
42
|
-
personalSpaceId,
|
|
43
|
-
subject = 'user',
|
|
44
|
-
specs,
|
|
45
|
-
sourceText,
|
|
46
|
-
sourceKind,
|
|
47
|
-
sourceUri = null,
|
|
48
|
-
sensitivity = Sensitivity.NORMAL,
|
|
49
|
-
confidence = 1
|
|
50
|
-
}) {
|
|
51
|
-
assertSafeSourceMetadata(sourceKind, sourceUri, SOURCE_METADATA_ERROR);
|
|
52
|
-
const specTexts = specs.flatMap(specTextsForValidation);
|
|
53
|
-
this.#validate(
|
|
54
|
-
personalSpaceId,
|
|
55
|
-
[subject, sourceText, ...specTexts],
|
|
56
|
-
sensitivity,
|
|
57
|
-
confidence
|
|
58
|
-
);
|
|
59
|
-
const episode = this.store.addEpisode(personalSpaceId, sourceKind, sourceText, sourceUri);
|
|
60
|
-
const facts = writePersonalFactSpecs(this.store, {
|
|
61
|
-
spaceId: personalSpaceId,
|
|
62
|
-
subject,
|
|
63
|
-
specs,
|
|
64
|
-
sourceEpisodeId: episode.id,
|
|
65
|
-
sensitivity,
|
|
66
|
-
confidence
|
|
67
|
-
});
|
|
68
|
-
return { episode, facts };
|
|
69
|
-
}
|
|
70
|
-
writeUserFactsForEpisodeInCurrentTransaction({
|
|
71
|
-
personalSpaceId,
|
|
72
|
-
subject = 'user',
|
|
73
|
-
specs,
|
|
74
|
-
episode,
|
|
75
|
-
sensitivity = Sensitivity.NORMAL,
|
|
76
|
-
confidence = 1
|
|
77
|
-
}) {
|
|
78
|
-
assertSafeSourceMetadata(episode?.sourceKind, episode?.sourceUri, SOURCE_METADATA_ERROR);
|
|
79
|
-
if (episode.spaceId !== personalSpaceId) {
|
|
80
|
-
throw new ApplicationError(ApplicationErrorCode.VALIDATION, 'Lens source episode belongs to another space');
|
|
81
|
-
}
|
|
82
|
-
this.#validate(
|
|
83
|
-
personalSpaceId,
|
|
84
|
-
[subject, episode.body, ...specs.flatMap(specTextsForValidation)],
|
|
85
|
-
sensitivity,
|
|
86
|
-
confidence
|
|
87
|
-
);
|
|
88
|
-
const facts = writePersonalFactSpecs(this.store, {
|
|
89
|
-
spaceId: personalSpaceId,
|
|
90
|
-
subject,
|
|
91
|
-
specs,
|
|
92
|
-
sourceEpisodeId: episode.id,
|
|
93
|
-
sensitivity,
|
|
94
|
-
confidence
|
|
95
|
-
});
|
|
96
|
-
return { episode, facts };
|
|
97
|
-
}
|
|
98
|
-
submitUserObservation({
|
|
99
|
-
personalSpaceId,
|
|
100
|
-
subject = 'user',
|
|
101
|
-
predicate,
|
|
102
|
-
value,
|
|
103
|
-
evidenceText,
|
|
104
|
-
inference,
|
|
105
|
-
sourceKind = 'observation',
|
|
106
|
-
sensitivity = Sensitivity.NORMAL,
|
|
107
|
-
confidence = inference === 'inferred' ? 0.5 : 1
|
|
108
|
-
}) {
|
|
109
|
-
assertSafeSourceMetadata(sourceKind, null, SOURCE_METADATA_ERROR);
|
|
110
|
-
this.#validate(personalSpaceId, [subject, predicate, value, evidenceText], sensitivity, confidence);
|
|
111
|
-
if (!OBSERVATION_INFERENCES.has(inference)) {
|
|
112
|
-
throw new ApplicationError(
|
|
113
|
-
ApplicationErrorCode.VALIDATION,
|
|
114
|
-
'Observation inference must be direct or inferred'
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
return this.store.transaction(() => {
|
|
118
|
-
const episode = this.store.addEpisode(personalSpaceId, sourceKind, evidenceText);
|
|
119
|
-
const fact = this.#writeFact({
|
|
120
|
-
personalSpaceId, subject, predicate, value, episode,
|
|
121
|
-
status: inference === 'inferred' ? FactStatus.SUGGESTED : FactStatus.OBSERVED,
|
|
122
|
-
sensitivity, confidence
|
|
123
|
-
});
|
|
124
|
-
return { episode, fact };
|
|
125
|
-
}, IMMEDIATE);
|
|
126
|
-
}
|
|
127
|
-
confirmObservation({ personalSpaceId, factId, sourceText, sourceKind = 'confirmation' }) {
|
|
128
|
-
assertSafeSourceMetadata(sourceKind, null, SOURCE_METADATA_ERROR);
|
|
129
|
-
requirePersonalSpace(this.store, personalSpaceId);
|
|
130
|
-
assertSafeLensTexts([sourceText]);
|
|
131
|
-
return this.store.transaction(() => {
|
|
132
|
-
const original = this.#findConfirmableFact(personalSpaceId, factId);
|
|
133
|
-
assertSafeLensTexts([original.subject, original.predicate, original.object]);
|
|
134
|
-
const episode = this.store.addEpisode(personalSpaceId, sourceKind, sourceText);
|
|
135
|
-
const fact = this.#writeFact({
|
|
136
|
-
personalSpaceId,
|
|
137
|
-
subject: original.subject,
|
|
138
|
-
predicate: original.predicate,
|
|
139
|
-
value: original.object,
|
|
140
|
-
episode,
|
|
141
|
-
status: FactStatus.CONFIRMED,
|
|
142
|
-
sensitivity: original.sensitivity,
|
|
143
|
-
confidence: original.confidence
|
|
144
|
-
});
|
|
145
|
-
return { episode, fact, replacedFact: original };
|
|
146
|
-
}, IMMEDIATE);
|
|
147
|
-
}
|
|
148
|
-
correctUserFact(input) {
|
|
149
|
-
assertSafeSourceMetadata(input.sourceKind, null, SOURCE_METADATA_ERROR);
|
|
150
|
-
return this.#corrections.correctUserFact(input);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
#writeFact(input) {
|
|
154
|
-
return writePersonalFact(this.store, {
|
|
155
|
-
spaceId: input.personalSpaceId,
|
|
156
|
-
subject: input.subject,
|
|
157
|
-
predicate: input.predicate,
|
|
158
|
-
object: input.value,
|
|
159
|
-
sourceEpisodeId: input.episode.id,
|
|
160
|
-
status: input.status,
|
|
161
|
-
sensitivity: input.sensitivity,
|
|
162
|
-
confidence: input.confidence
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
#validate(personalSpaceId, texts, sensitivity, confidence) {
|
|
166
|
-
validateLensWriteInput(this.store, { personalSpaceId, texts, sensitivity, confidence });
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
#findConfirmableFact(personalSpaceId, factId) {
|
|
170
|
-
const fact = this.store.getFact(factId);
|
|
171
|
-
if (!fact || fact.spaceId !== personalSpaceId || fact.scope !== FactScope.PERSONAL) {
|
|
172
|
-
throw new ApplicationError(ApplicationErrorCode.NOT_FOUND, `Observation not found: ${factId}`);
|
|
173
|
-
}
|
|
174
|
-
if (fact.invalidAt || ![FactStatus.OBSERVED, FactStatus.SUGGESTED].includes(fact.status)) {
|
|
175
|
-
throw new ApplicationError(ApplicationErrorCode.VALIDATION, `Observation cannot be confirmed: ${factId}`);
|
|
176
|
-
}
|
|
177
|
-
return fact;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
function specTextsForValidation(spec) {
|
|
181
|
-
return [spec.subject, spec.predicate, spec.object, spec.oldValue, spec.newValue]
|
|
182
|
-
.filter(value => value !== undefined);
|
|
183
|
-
}
|
package/src/models.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
export const SpaceKind = Object.freeze({
|
|
2
|
-
PERSONAL: 'personal',
|
|
3
|
-
PUBLIC: 'public'
|
|
4
|
-
});
|
|
5
|
-
|
|
6
|
-
export const CandidateStatus = Object.freeze({
|
|
7
|
-
PENDING: 'pending',
|
|
8
|
-
SYNCED: 'synced',
|
|
9
|
-
PERSONAL_ONLY: 'personal_only',
|
|
10
|
-
IGNORED: 'ignored'
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
export const FactStatus = Object.freeze({
|
|
14
|
-
CONFIRMED: 'confirmed',
|
|
15
|
-
OBSERVED: 'observed',
|
|
16
|
-
SUGGESTED: 'suggested',
|
|
17
|
-
REJECTED: 'rejected',
|
|
18
|
-
DEPRECATED: 'deprecated'
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
export const Sensitivity = Object.freeze({
|
|
22
|
-
NORMAL: 'normal',
|
|
23
|
-
PRIVATE: 'private',
|
|
24
|
-
RESTRICTED: 'restricted'
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
export const FactScope = Object.freeze({
|
|
28
|
-
PERSONAL: 'personal',
|
|
29
|
-
PUBLIC: 'public'
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
export const PublishRoute = Object.freeze({
|
|
33
|
-
PERSONAL: 'personal',
|
|
34
|
-
PUBLIC: 'public',
|
|
35
|
-
CANDIDATE: 'candidate'
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
export function nowIso() {
|
|
39
|
-
return new Date().toISOString();
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function isCurrentFact(fact) {
|
|
43
|
-
return !fact.invalidAt && ![FactStatus.REJECTED, FactStatus.DEPRECATED].includes(fact.status);
|
|
44
|
-
}
|
package/src/observer.js
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
import { IngestionService } from './ingestion.js';
|
|
4
|
-
|
|
5
|
-
const CONTEXT_MARKERS = ['可能', '也许', '不确定', '待确认', '以后要', '似乎'];
|
|
6
|
-
const URL_PATTERN = /https?:\/\//;
|
|
7
|
-
const KEY_VALUE_PATTERN = /^[\p{L}\p{N}_.-]+\s*[::]\s*\S/u;
|
|
8
|
-
const FORBIDDEN_RULE_PATTERN =
|
|
9
|
-
/^(?:这个项目|本项目|项目)?\s*(?:禁止|不要|不允许)\s*(?:使用|采用|引入|用)?\s*\S+/;
|
|
10
|
-
|
|
11
|
-
export function observeGitDiff({ store, personalSpaceId, targetSpaceId = null, cwd = process.cwd() }) {
|
|
12
|
-
const service = new IngestionService(store);
|
|
13
|
-
const resolvedTargetSpaceId = targetSpaceId ?? inferOnlySubscribedSpace(store, personalSpaceId);
|
|
14
|
-
const observed = collectGitDiffEpisodes({ cwd }).map((episode) => ({
|
|
15
|
-
...episode,
|
|
16
|
-
result: service.remember({
|
|
17
|
-
personalSpaceId,
|
|
18
|
-
targetSpaceId: resolvedTargetSpaceId,
|
|
19
|
-
sourceKind: 'git',
|
|
20
|
-
body: episode.body,
|
|
21
|
-
sourceUri: episode.sourceUri
|
|
22
|
-
})
|
|
23
|
-
}));
|
|
24
|
-
|
|
25
|
-
return { observed };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function inferOnlySubscribedSpace(store, personalSpaceId) {
|
|
29
|
-
const subscriptions = store.subscriptionsFor(personalSpaceId);
|
|
30
|
-
return subscriptions.length === 1 ? subscriptions[0].spaceId : null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function collectGitDiffEpisodes({ cwd = process.cwd(), maxBuffer = 1024 * 1024 } = {}) {
|
|
34
|
-
const diff = execFileSync('git', ['diff', '--unified=0', '--no-ext-diff'], {
|
|
35
|
-
cwd,
|
|
36
|
-
encoding: 'utf8',
|
|
37
|
-
maxBuffer
|
|
38
|
-
});
|
|
39
|
-
return parseGitDiff(diff);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function parseGitDiff(diff) {
|
|
43
|
-
const episodes = [];
|
|
44
|
-
let currentPath = null;
|
|
45
|
-
|
|
46
|
-
for (const rawLine of diff.split(/\r?\n/)) {
|
|
47
|
-
if (rawLine.startsWith('+++ b/')) {
|
|
48
|
-
currentPath = rawLine.slice('+++ b/'.length);
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (rawLine.startsWith('+++ ')) {
|
|
53
|
-
currentPath = null;
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (!rawLine.startsWith('+')) continue;
|
|
58
|
-
|
|
59
|
-
const body = rawLine.slice(1).trim();
|
|
60
|
-
if (!body) continue;
|
|
61
|
-
if (!isContextWorthyLine(body)) continue;
|
|
62
|
-
|
|
63
|
-
episodes.push({
|
|
64
|
-
body,
|
|
65
|
-
sourceUri: `git-diff:${currentPath ?? 'unknown'}`
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return episodes;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function isContextWorthyLine(body) {
|
|
73
|
-
return (
|
|
74
|
-
KEY_VALUE_PATTERN.test(body) ||
|
|
75
|
-
URL_PATTERN.test(body) ||
|
|
76
|
-
body.startsWith('替代:') ||
|
|
77
|
-
body.startsWith('替代:') ||
|
|
78
|
-
body.startsWith('禁止:') ||
|
|
79
|
-
body.startsWith('禁止:') ||
|
|
80
|
-
FORBIDDEN_RULE_PATTERN.test(body) ||
|
|
81
|
-
CONTEXT_MARKERS.some((marker) => body.includes(marker))
|
|
82
|
-
);
|
|
83
|
-
}
|
package/src/project-rules.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
export function getProjectRules(store, spaceId) {
|
|
2
|
-
const facts = store.currentFacts(spaceId).map((fact) => enrichFact(store, fact));
|
|
3
|
-
|
|
4
|
-
return {
|
|
5
|
-
spaceId,
|
|
6
|
-
spaceName: store.getSpace(spaceId)?.name ?? spaceId,
|
|
7
|
-
forbidden: facts.filter((fact) => fact.predicate === 'forbids'),
|
|
8
|
-
parameters: facts.filter(isProjectParameter),
|
|
9
|
-
links: facts.filter((fact) => fact.predicate === 'has_url'),
|
|
10
|
-
facts
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function enrichFact(store, fact) {
|
|
15
|
-
const episode = store.getEpisode(fact.sourceEpisodeId);
|
|
16
|
-
|
|
17
|
-
return {
|
|
18
|
-
...fact,
|
|
19
|
-
source: episode
|
|
20
|
-
? {
|
|
21
|
-
id: episode.id,
|
|
22
|
-
kind: episode.sourceKind,
|
|
23
|
-
uri: episode.sourceUri,
|
|
24
|
-
body: episode.body,
|
|
25
|
-
createdAt: episode.createdAt
|
|
26
|
-
}
|
|
27
|
-
: null
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function isProjectParameter(fact) {
|
|
32
|
-
return fact.predicate.startsWith('has_') && fact.predicate !== 'has_url';
|
|
33
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
const MAX_LOOKAHEAD = '9999-12-31T23:59:59.999Z';
|
|
2
|
-
const SAFE_FAILURE = 'Publication delivery failed';
|
|
3
|
-
|
|
4
|
-
export class OutboxService {
|
|
5
|
-
constructor(store, { now = () => new Date() } = {}) {
|
|
6
|
-
this.store = store;
|
|
7
|
-
this.now = now;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
pending(at = this.now()) {
|
|
11
|
-
return this.store.listPendingOutbox(toIso(at));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
markSent(id) {
|
|
15
|
-
return this.store.transaction(() => {
|
|
16
|
-
this.#requirePending(id);
|
|
17
|
-
return this.store.markOutboxSent(id, toIso(this.now()));
|
|
18
|
-
}, { mode: 'immediate' });
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
markFailed(id, _error) {
|
|
22
|
-
return this.store.transaction(() => {
|
|
23
|
-
const row = this.#requirePending(id);
|
|
24
|
-
const now = new Date(this.now());
|
|
25
|
-
const delayMinutes = retryDelayMinutes(row.attempts + 1);
|
|
26
|
-
const nextAttemptAt = new Date(now.getTime() + delayMinutes * 60_000).toISOString();
|
|
27
|
-
return this.store.markOutboxFailed(id, SAFE_FAILURE, nextAttemptAt);
|
|
28
|
-
}, { mode: 'immediate' });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
#requirePending(id) {
|
|
32
|
-
const row = this.store.listPendingOutbox(MAX_LOOKAHEAD).find((item) => item.id === id);
|
|
33
|
-
if (!row) throw new Error(`Pending Outbox row not found: ${id}`);
|
|
34
|
-
return row;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function retryDelayMinutes(attempt) {
|
|
39
|
-
return Math.min(30, 2 ** Math.max(0, attempt - 1));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function toIso(value) {
|
|
43
|
-
const date = value instanceof Date ? value : new Date(value);
|
|
44
|
-
if (Number.isNaN(date.getTime())) throw new TypeError('Outbox time must be valid');
|
|
45
|
-
return date.toISOString();
|
|
46
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
const ENVELOPE_KEYS = ['contentHash', 'facts', 'id', 'policyVersion', 'source', 'spaceId'];
|
|
2
|
-
const SOURCE_KEYS = ['capturedAt', 'episodeId', 'kind', 'uri'];
|
|
3
|
-
const FACT_KEYS = ['id', 'object', 'predicate', 'sourceEpisodeId', 'subject'];
|
|
4
|
-
|
|
5
|
-
export function validateEnvelopeSchema(envelope) {
|
|
6
|
-
assertExactObject('Publication envelope', envelope, ENVELOPE_KEYS);
|
|
7
|
-
assertNonEmptyString('Publication envelope id', envelope.id);
|
|
8
|
-
assertNonEmptyString('Publication envelope spaceId', envelope.spaceId);
|
|
9
|
-
if (envelope.policyVersion !== '1') {
|
|
10
|
-
throw new TypeError('Publication envelope policyVersion must be 1');
|
|
11
|
-
}
|
|
12
|
-
if (!/^[a-f0-9]{64}$/.test(envelope.contentHash)) {
|
|
13
|
-
throw new TypeError('Publication envelope contentHash must be lowercase SHA-256');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
validateSource(envelope.source);
|
|
17
|
-
validateFacts(envelope.facts, envelope.source.episodeId);
|
|
18
|
-
return envelope;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function validateSource(source) {
|
|
22
|
-
assertExactObject('Publication source', source, SOURCE_KEYS);
|
|
23
|
-
assertNonEmptyString('Publication source episodeId', source.episodeId);
|
|
24
|
-
assertNonEmptyString('Publication source kind', source.kind);
|
|
25
|
-
if (source.uri !== null && typeof source.uri !== 'string') {
|
|
26
|
-
throw new TypeError('Publication source uri must be a string or null');
|
|
27
|
-
}
|
|
28
|
-
assertNonEmptyString('Publication source capturedAt', source.capturedAt);
|
|
29
|
-
const capturedAt = new Date(source.capturedAt);
|
|
30
|
-
if (Number.isNaN(capturedAt.getTime()) || capturedAt.toISOString() !== source.capturedAt) {
|
|
31
|
-
throw new TypeError('Publication source capturedAt must be an ISO timestamp');
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function validateFacts(facts, episodeId) {
|
|
36
|
-
if (!Array.isArray(facts) || facts.length === 0) {
|
|
37
|
-
throw new TypeError('Publication envelope facts must be a non-empty array');
|
|
38
|
-
}
|
|
39
|
-
const ids = new Set();
|
|
40
|
-
for (const fact of facts) {
|
|
41
|
-
assertExactObject('Publication fact', fact, FACT_KEYS);
|
|
42
|
-
assertNonEmptyString('Publication fact id', fact.id);
|
|
43
|
-
assertNonEmptyString('Publication fact subject', fact.subject);
|
|
44
|
-
assertNonEmptyString('Publication fact predicate', fact.predicate);
|
|
45
|
-
if (typeof fact.object !== 'string') {
|
|
46
|
-
throw new TypeError('Publication fact object must be a string');
|
|
47
|
-
}
|
|
48
|
-
assertNonEmptyString('Publication fact sourceEpisodeId', fact.sourceEpisodeId);
|
|
49
|
-
if (fact.sourceEpisodeId !== episodeId) {
|
|
50
|
-
throw new TypeError('Publication fact sourceEpisodeId must match the source episode');
|
|
51
|
-
}
|
|
52
|
-
if (ids.has(fact.id)) throw new TypeError(`Duplicate publication fact id: ${fact.id}`);
|
|
53
|
-
ids.add(fact.id);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function assertExactObject(label, value, expectedKeys) {
|
|
58
|
-
if (!isPlainObject(value)) throw new TypeError(`${label} must be a plain object`);
|
|
59
|
-
const keys = Object.keys(value).sort();
|
|
60
|
-
if (keys.length !== expectedKeys.length || keys.some((key, index) => key !== expectedKeys[index])) {
|
|
61
|
-
throw new TypeError(`${label} has invalid schema keys`);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function assertNonEmptyString(label, value) {
|
|
66
|
-
if (typeof value !== 'string' || value.length === 0) {
|
|
67
|
-
throw new TypeError(`${label} must be a non-empty string`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function isPlainObject(value) {
|
|
72
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
73
|
-
const prototype = Object.getPrototypeOf(value);
|
|
74
|
-
return prototype === Object.prototype || prototype === null;
|
|
75
|
-
}
|